From 562e09707a5e4700a46e7501c9b01138015fc642 Mon Sep 17 00:00:00 2001 From: Juan Manuel Verges Solanas Date: Fri, 9 Oct 2026 19:03:09 +0000 Subject: [PATCH] Fix #813: type can name an installed version of its font A font library can hold two versions of one family and style (a new Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept only the first one loaded, so type set in the other couldn't be matched when a PDF or .ai was opened: it came in as outlines. Now every version loads (the first stays the one family and style resolve to), PDF import tries the other versions when the glyphs differ, and type set in a version the family and style alone wouldn't pick names it in CharStyle::font_version (left out when None, so documents without it are unchanged). Layout, shaping, the substituted-glyph highlight, Find Font, Package and Document Info follow it; changing the font clears it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/text.rs | 6 +++ crates/engine/src/cmd/docinfo.rs | 20 +++++---- crates/engine/src/cmd/fonts.rs | 44 ++++++++++++------ crates/engine/src/cmd/package.rs | 12 +++-- crates/engine/src/cmd/textedit.rs | 8 +++- crates/engine/src/cmd/typecmd.rs | 4 ++ crates/pdf/src/import.rs | 23 +++++++++- crates/pdf/src/import_text.rs | 3 ++ crates/pdf/src/tests_live_text.rs | 2 + crates/pdf/src/tests_realtext.rs | 23 ++++++++++ crates/render/src/lib.rs | 6 ++- crates/text/src/fontdb.rs | 74 +++++++++++++++++++++++++++++-- crates/text/src/layout.rs | 6 ++- crates/text/src/lib.rs | 2 + crates/text/src/shape.rs | 12 +++-- crates/text/src/test_fonts.rs | 27 +++++++++++ crates/text/src/tests_versions.rs | 43 ++++++++++++++++++ 18 files changed, 279 insertions(+), 38 deletions(-) create mode 100644 crates/text/src/tests_versions.rs diff --git a/ROADMAP.md b/ROADMAP.md index f18d3a53..6554d0ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ec9ca824..aef35e23 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -27,6 +27,11 @@ pub struct CharStyle { pub font_family: String, #[serde(default = "regular")] pub font_style: String, + /// Which installed version of the font (its version string) when several versions of the + /// family and style are installed and the text was set in one the family and style alone + /// wouldn't pick (an imported file's type matched to it); None = the one they pick. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_version: Option, /// Size in points. pub size: f64, /// Leading in points; None = Auto (120% of size). @@ -175,6 +180,7 @@ impl Default for CharStyle { Self { font_family: "Source Sans 3".into(), font_style: "Regular".into(), + font_version: None, size: 12.0, leading: None, tracking: 0.0, diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 202be0d5..5af0106f 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -97,7 +97,7 @@ type ImageRow = (String, u32, u32, Option); /// The Document Info categories of `d` (only `category` when given) from `info` (what /// `document.info` reports), its fonts `(family, style)`, image objects and used patterns. -fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec
{ +fn sections(d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], category: Option<&str>) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect() }; @@ -135,14 +135,18 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag "patterns" => names("patternNames"), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), "symbols" => names("symbols"), - "fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(), + "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), "fontDetails" => { let db = vectorcraft_text::FontDb::global(); fonts .iter() - .map(|(family, style)| { - // Found by any of its names; a style the family lacks shows in another. - let detail = match db.resolve(family, style) { + .map(|used| { + let (family, style, version) = used; + // Found by any of its names (in the version the type names); a style the + // family lacks shows in another. + let resolved = + db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); + let detail = match resolved { Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { let file = f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); @@ -156,7 +160,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), None => "missing".into(), }; - (format!("{family} {style}"), detail) + (super::fonts::font_label(used), detail) }) .collect() } @@ -243,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result { NodeKind::Compound { .. } => Some("compoundPaths"), NodeKind::Text(t) => { for run in &t.runs { - fonts.insert((run.style.font_family.clone(), run.style.font_style.clone())); + fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone())); } Some("textObjects") } @@ -300,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result { } }); } - let font_names: Vec = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect(); + let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); let mut out = json!({ "document": document_summary(d), "objects": counts, diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 45142990..71d642c3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", has_doc, fonts ), @@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result> { Ok(v) } -/// The fonts (family, style) of the type in the layers and symbols of `d`. -pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> { +/// A font as type names it: family, style and, when it names one, the installed version. +pub(super) type UsedFont = (String, String, Option); + +/// How a font is named in reports: family and style, then the version the type names, if any. +pub(super) fn font_label((family, style, version): &UsedFont) -> String { + match version { + Some(v) => format!("{family} {style} ({v})"), + None => format!("{family} {style}"), + } +} + +/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +pub(super) fn used_fonts(d: &Document) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); + fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); } }; d.walk(&mut add); @@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option { let db = vectorcraft_text::FontDb::global(); let mut missing: Vec = used_fonts(d) .into_iter() - .filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) - .map(|(family, _)| family) + .filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) + .map(|(family, _, _)| family) .collect(); - // Sorted by family: its styles are neighbours. + // Sorted by family: its styles and versions are neighbours. missing.dedup(); (!missing.is_empty()).then(|| { format!( @@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result { let ids = scope(s, bool_or(p, "selectionOnly", false))?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - let mut found: BTreeMap<(String, String), (usize, Vec, String)> = BTreeMap::new(); + let mut found: BTreeMap, String)> = BTreeMap::new(); for id in ids { for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() { - let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default(); + let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default(); e.0 += 1; e.2.push_str(&r.text); if !e.1.contains(&id) { @@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style), (runs, objects, chars))| { - let resolved = db.resolve(&family, &style); + .map(|((family, style, version), (runs, objects, chars))| { + // The face the type is set in: the version it names when that one is installed. + let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); lacking.sort_unstable(); lacking.dedup(); lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); - json!({ + let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": status == vectorcraft_text::FontMatch::Missing, "status": status.as_str(), "resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })), "missingGlyphs": lacking.len(), - }) + }); + if let Some(v) = version { + row["version"] = json!(v); + } + row }) .collect(); Ok(json!(list)) @@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result { .unwrap_or_else(|| r.style.font_style.clone()); r.style.font_family = tf.clone(); r.style.font_style = style; + // Another font: none of the old one's versions. + r.style.font_version = None; changed = true; n += 1; } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 046db3a0..c3399072 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -107,10 +107,14 @@ fn package(s: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); let mut files = HashSet::new(); lines.push("FONTS".to_string()); - for (family, style) in super::fonts::used_fonts(&st.doc) { - let font = format!("{family} {style}"); - // Found by any of its names, as the canvas draws it. - let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing); + for used in super::fonts::used_fonts(&st.doc) { + let font = super::fonts::font_label(&used); + let (family, style, version) = used; + // Found by any of its names, as the canvas draws it (in the version the type names). + let resolved = db + .resolve(&family, &style) + .filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing) + .map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); // A style the family lacks is shown in its closest style, whose file is copied. let shown = match &resolved { Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 1b207e86..729194c9 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -316,6 +316,10 @@ impl CharChange { if let Some(v) = &self.style { st.font_style = v.clone(); } + // Another font: none of the old one's versions. + if self.font.is_some() || self.style.is_some() { + st.font_version = None; + } if let Some(v) = self.size { st.size = v.clamp(0.1, 1296.0); } @@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec let db = vectorcraft_text::FontDb::global(); let mut faces = std::collections::HashMap::new(); let mut covers = |st: &CharStyle, c: char| { - let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style)); + let face = faces + .entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone())) + .or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref())); face.as_ref().map(|f| f.covers(c)) }; // Byte ranges that keep a font (family, style). diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7604e908..a9921c6e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -416,6 +416,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result { if let Some(f) = &style { st.font_style = f.clone(); } + // Another font: none of the old one's versions. + if font.is_some() || style.is_some() { + st.font_version = None; + } if let Some(v) = size { st.size = v.clamp(0.1, 1296.0); } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 92202a24..b59f28c3 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -460,8 +460,12 @@ fn matching_face( return Some(face.clone()); } let db = vectorcraft_text::FontDb::global(); - let others = + // Other installed versions of its family and style (a font library keeps old ones), then the + // family's other styles. + let versions = db.versions(face).into_iter().skip(1).take(16); + let styles = db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s)); + let others = versions.chain(styles); let mut best = own.map(|d| (d, face.clone())); for f in others { if let Some(d) = glyph_deviation(&f, text, o, vertical) @@ -636,6 +640,9 @@ struct Builder<'p> { /// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph /// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines. matched: HashMap>>, + /// Per font: the installed version its type names, when it isn't the one its family and style + /// resolve to (see `CharStyle::font_version`). + versions: HashMap>, /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. mask: Option>, masked: Vec>, @@ -784,6 +791,7 @@ impl<'p> Builder<'p> { cid_text: HashMap::new(), font_chars: HashMap::new(), matched: HashMap::new(), + versions: HashMap::new(), mask: None, masked: vec![], masks: HashMap::new(), @@ -1356,12 +1364,25 @@ impl<'p> Builder<'p> { }; info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) }; } + // A version of the family and style that the two alone don't resolve to: the type names it. + // Kept per font once its face is decided (until then each glyph asks). + let named = |f: &Option>| { + let f = f.as_ref()?; + let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?; + (default.version != f.version).then(|| f.version.clone()) + }; + let version = match self.versions.get(&key) { + Some(v) => v.clone(), + None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(), + None => named(&info.face), + }; let (paint, opacity) = self.paint(paint, stroke.is_some()); let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale)); let look = Look { font: key, family: info.family, style: info.style, + version, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 1b654a08..a8d29a98 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -75,6 +75,8 @@ pub(crate) struct Look { pub font: u128, pub family: String, pub style: String, + /// The installed version of the font when it isn't the one family and style resolve to. + pub version: Option, pub size: f64, pub h_scale: f64, pub fill: Option, @@ -97,6 +99,7 @@ impl Look { CharStyle { font_family: self.family.clone(), font_style: self.style.clone(), + font_version: self.version.clone(), size: round(self.size), h_scale: round(self.h_scale), fill: self.fill.clone().unwrap_or(Paint::None), diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index 0cbf9ecc..bface79d 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp font: 1, family: "Source Sans 3".into(), style: "Regular".into(), + version: None, size: 20.0, h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), @@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() { font: 1, family: "Source Sans 3".into(), style: "Regular".into(), + version: None, size: 20.0, h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index ef3f770d..5d088c53 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() { assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}"); } } + +/// Type set in an installed font's older version (a library keeps both, one name) reopens as type +/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type +/// names the version, which the family and style alone don't pick. +#[test] +fn type_in_another_installed_version_reopens_in_that_version() { + use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font}; + // The newer version first: the one the family and style resolve to. + FontDb::global().add_font(twin_font(true).unwrap()); + FontDb::global().add_font(twin_font(false).unwrap()); + for version in [None, Some(TWIN_VERSIONS[0])] { + let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) }; + let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let mut found = vec![]; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found.push((t.plain_text(), t.first_style().font_version.clone())); + } + }); + assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a50cadc5..3ca4de81 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { .runs .iter() .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { - Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())), + // The version the type names, when it's installed (see `FontDb::face_version`). + Some((f, m)) => { + let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f); + (m == vectorcraft_text::FontMatch::Missing, Some(face.id())) + } None => (true, None), }) .collect(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8ac24ef1..74aec355 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -52,6 +52,9 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two + /// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]). + pub version: String, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -324,6 +327,10 @@ pub struct FontDb { /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock<()>, + /// Families (lowercased) whose installed files were all loaded to find other versions of their + /// faces (see [`FontDb::versions`]); a rescan forgets them. + #[cfg(not(target_arch = "wasm32"))] + version_scanned: RwLock>, /// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned. family_cache: Mutex>>, menu_cache: Mutex>, @@ -1032,6 +1039,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option Vec { let (family, _) = self.canonical(family, ""); let family = family.as_str(); - let mut v: Vec<(bool, f32, String)> = - self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); + let mut v: Vec<(bool, f32, String)> = Vec::new(); + // Each style once, as its first loaded version describes it (versions can disagree on weight). + for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) { + if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) { + v.push((f.italic, f.weight, f.style.clone())); + } + } if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { for cf in &c.faces { if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) { @@ -1267,7 +1283,14 @@ impl FontDb { let data = Arc::new(bytes); let mut added = 0; for (i, spec) in enumerate_faces(&data) { - if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) { + // Another version of a family and style is kept: a document may name it (the first one + // loaded stays the one the family and style alone resolve to). + let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default(); + if self + .read_faces() + .iter() + .any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version) + { continue; } if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) { @@ -1329,6 +1352,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { + // Files may have been installed since: other versions are looked for again. + self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear(); let mut catalog = Catalog::new(); let mut aliases: HashMap> = HashMap::new(); let mut postscript = PostScriptNames::new(); @@ -1520,6 +1545,49 @@ impl FontDb { .or_else(|| self.read_faces().first().cloned()) } + /// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when + /// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives. + pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option> { + let face = self.face(family, style)?; + let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) }; + let same = + |f: &&Arc| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version; + if let Some(f) = self.read_faces().iter().find(same) { + return Some(f.clone()); + } + // Not loaded: the family's installed files may hold it (the catalog doesn't list versions). + self.load_family_files(&face.family); + let found = self.read_faces().iter().find(same).cloned(); + Some(found.unwrap_or(face)) + } + + /// Every installed version of `face`'s family and style, `face` itself first. + pub fn versions(&self, face: &Arc) -> Vec> { + self.load_family_files(&face.family); + let mut out = vec![face.clone()]; + out.extend( + self.read_faces() + .iter() + .filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id) + .cloned(), + ); + out + } + + /// Load the installed files of `family` once (other versions of faces already loaded from + /// elsewhere); a no-op on wasm, where no fonts are installed. + fn load_family_files(&self, family: &str) { + #[cfg(not(target_arch = "wasm32"))] + { + let key = family.to_ascii_lowercase(); + if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) { + self.load_cataloged(family); + } + } + #[cfg(target_arch = "wasm32")] + let _ = family; + } + /// Is `style` of `family` among the installed fonts? #[cfg(not(target_arch = "wasm32"))] fn is_cataloged(&self, family: &str, style: &str) -> bool { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5e1b4dc9..73de86f1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -263,7 +263,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout TextKind::OnPath { .. } => Affine::IDENTITY, TextKind::Point => { let first = cx.style_at(0); - let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = + db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre()); Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN } _ => QUARTER_TURN, @@ -1392,7 +1393,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start)); let st = cx.style_at(pr.start); let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = + cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre()); Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } }; if pi > 0 { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2c05b799..a499dc67 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -522,6 +522,8 @@ mod tests_typo; #[cfg(test)] mod tests_variable; #[cfg(test)] +mod tests_versions; +#[cfg(test)] mod tests_vertical; #[cfg(test)] #[path = "../build/web_fonts.rs"] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 503614a0..f82f447c 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool { /// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) }; + let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { + return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()); + }; let k = st.size / face.upem; (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } @@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { /// Cap height and x height (points) of a style's resolved face. pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) }; + let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { + return (st.size * 0.7 * vs, st.size * 0.5 * vs); + }; let k = st.size / face.upem * vs; (face.cap_height * k, face.x_height * k) } @@ -291,12 +295,12 @@ pub(crate) fn shape_range( let a = bytes.start; let b = bytes.end; if let Some(art) = art { - if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) { + if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) { out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a))); } continue; } - let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue }; + let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). let small_caps = st.small_caps.is_some() && !st.all_caps; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index fd687522..688f05ef 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option> { } Some(builder.build()) } + +/// The family of [`twin_font`]: two versions of one family and style. +pub const TWIN_FAMILY: &str = "Twintest Sans"; + +/// The version strings of [`twin_font`]'s older and newer face. +pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"]; + +/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled +/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of +/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't +/// be built. +pub fn twin_font(newer: bool) -> Option> { + const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?; + let version = TWIN_VERSIONS[usize::from(newer)]; + let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} diff --git a/crates/text/src/tests_versions.rs b/crates/text/src/tests_versions.rs new file mode 100644 index 00000000..91250fdd --- /dev/null +++ b/crates/text/src/tests_versions.rs @@ -0,0 +1,43 @@ +//! Two installed versions of one family and style (a font library keeps an old version beside a +//! new one): both load, the family and style alone keep resolving to the first, and type that names +//! the other version is set in it. + +use kurbo::Point; +use vectorcraft_doc::CharStyle; + +use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font}; +use super::*; + +/// A database of the bundled fonts, the newer version of the test family loaded first. +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1); + assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept"); + assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice"); + db +} + +fn width(db: &FontDb, version: Option<&str>) -> f64 { + let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() }; + layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum() +} + +#[test] +fn a_version_is_found_by_its_version_string() { + let db = db(); + let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face"); + assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default"); + assert_eq!(face(None).version, VERSIONS[1]); + assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]); + assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default"); + let all = db.versions(&face(None)); + assert_eq!(all.iter().map(|f| f.version.as_str()).collect::>(), [VERSIONS[1], VERSIONS[0]]); +} + +#[test] +fn type_naming_a_version_is_set_in_it() { + let db = db(); + let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0]))); + assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}"); + assert_eq!(width(&db, Some(VERSIONS[1])), newer); +}