From 09f065c11f98f3c5c234beec84e17805842749fb Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Fri, 16 Oct 2026 13:48:31 +0100 Subject: [PATCH] 286: resolve installed PostScript font names --- crates/fonts/src/fontdb.rs | 54 ++++++++++++++++++++++++------ crates/fonts/src/tests_sysfonts.rs | 28 ++++++++++++++++ docs/layout-parity.md | 5 ++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5f0729513..2f5d4aa49 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -60,6 +60,8 @@ pub struct FontFace { /// The family's names in other languages (宋体 for SimSun), from the `name` table. Lookups by /// family name accept them; the font list shows them in a matching interface language. pub local_names: Vec, + /// Exact OpenType name ID 6, distinct from family and style names. + postscript: Option, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -179,9 +181,11 @@ impl FontFace { } pen.0 } - /// Is `family` this face's typographic or legacy family name? + /// Is `family` this face's typographic, legacy or PostScript name? pub fn named(&self, family: &str) -> bool { - self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family) + self.family.eq_ignore_ascii_case(family) + || self.legacy_family.eq_ignore_ascii_case(family) + || self.postscript.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(family)) } /// Does the face map `c` to a glyph? pub fn covers(&self, c: char) -> bool { @@ -290,6 +294,7 @@ struct CatalogEntry { family: String, style: String, legacy: String, + postscript: Option, local: Vec, /// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]). east_asian: bool, @@ -748,6 +753,8 @@ struct Found { style: String, /// The legacy family name ([`FontFace::legacy_family`]). legacy: String, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + postscript: Option, /// The family's names in other languages ([`FontFace::local_names`]). local: Vec, /// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]); the scan's catalog keeps it. @@ -775,13 +782,13 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let legacy = legacy_family(&family, &style); // Name ID 1 names the default instance only: keep the names of the whole family. let local = local.iter().filter(|n| n.english.eq_ignore_ascii_case(&family)).cloned().collect(); - named.push(Found { index, family: family.clone(), style, legacy, local, east_asian, coords }); + named.push(Found { index, family: family.clone(), style, legacy, postscript: None, local, east_asian, coords }); } if !named.is_empty() { return named; } let legacy = id1.filter(|n| !n.eq_ignore_ascii_case(&family)).unwrap_or_else(|| legacy_family(&family, &style)); - vec![Found { index, family, style, legacy, local, east_asian, coords: Vec::new() }] + vec![Found { index, family, style, legacy, postscript: name(f, &[StringId::POSTSCRIPT_NAME]), local, east_asian, coords: Vec::new() }] } /// Parse every face in `data` (a font file or collection); a variable font yields one face per @@ -835,6 +842,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy style, legacy_family, local_names: Vec::new(), + postscript: coords.is_empty().then(|| name(&f, &[StringId::POSTSCRIPT_NAME])).flatten(), weight, italic, upem: m.units_per_em.max(1) as f64, @@ -1158,6 +1166,7 @@ impl FontDb { family: f.family, style: f.style, legacy: f.legacy, + postscript: f.postscript, local: f.local, east_asian: f.east_asian, path: p.clone(), @@ -1186,7 +1195,11 @@ impl FontDb { let cat = self.read_catalog(); let families: Vec<&str> = cat .iter() - .filter(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) + .filter(|c| { + c.family.eq_ignore_ascii_case(family) + || c.legacy.eq_ignore_ascii_case(family) + || c.postscript.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(family)) + }) .map(|c| c.family.as_str()) .collect(); let mut p: Vec<_> = cat.iter().filter(|c| families.iter().any(|f| c.family.eq_ignore_ascii_case(f))).map(|c| c.path.clone()).collect(); @@ -1201,7 +1214,7 @@ impl FontDb { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. `family` is a typographic family ("Roboto") or a face's legacy family /// name ("Roboto Light", whose styles are Light and Light Italic). Installed system fonts are - /// found by name whatever ran before. + /// found by name whatever ran before. An exact PostScript name selects its face. pub fn face(&self, family: &str, style: &str) -> Arc { if let Some(f) = self.find(family, style) { return f; @@ -1299,19 +1312,23 @@ impl FontDb { } /// Is `family` available (loaded, or installed on the system)? English and localized family - /// names both count. + /// names and exact PostScript face names count. pub fn has_family(&self, family: &str) -> bool { if self.is_loaded(family) { return true; } #[cfg(not(target_arch = "wasm32"))] - if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family) || c.legacy.eq_ignore_ascii_case(family)) { + if self.read_catalog().iter().any(|c| { + c.family.eq_ignore_ascii_case(family) + || c.legacy.eq_ignore_ascii_case(family) + || c.postscript.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(family)) + }) { return true; } self.english_family(family).is_some() } - /// Is a face named `family` (its typographic or legacy family) loaded? + /// Is a face named `family` (its typographic, legacy or PostScript name) loaded? pub fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.named(family)) } @@ -1375,7 +1392,8 @@ impl FontDb { cands = faces.iter().filter(|f| f.legacy_family.eq_ignore_ascii_case(family)).collect(); } if cands.is_empty() { - return None; + // A PostScript name selects one exact face, regardless of the requested style. + return faces.iter().find(|f| f.postscript.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(family))).cloned(); } let ns = norm(style); if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { @@ -1790,4 +1808,20 @@ mod tests { assert_eq!(a.id(), b.id()); assert_eq!(a.legacy_family, "Inter Medium"); } + + #[test] + fn postscript_names_select_faces() { + let db = FontDb::with_font_dirs(vec![]); + for style in ["Regular", "Bold", "Italic", "Bold Italic"] { + let expected = db.face("Source Serif 4", style); + let f = expected.skrifa().unwrap(); + let ps = name(&f, &[StringId::POSTSCRIPT_NAME]).unwrap(); + assert!(db.has_family(&ps), "{ps}"); + let actual = db.face(&ps, "Regular"); + assert_eq!(actual.id(), expected.id(), "{ps}"); + assert_eq!(db.face(&ps.to_ascii_lowercase(), "Bold").id(), expected.id()); + } + assert!(!db.has_family("NotAFont-Bold")); + assert_eq!(db.face("Source Serif 4", "Regular").style, "Regular"); + } } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index d5db20633..6120f1493 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -456,3 +456,31 @@ fn font_list_previews_load_nothing_for_good() { assert_eq!(db.loaded_font_bytes(), used, "memory stays bounded across previews"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn catalog_postscript_names() { + let dir = font_dir("postscript"); + for path in [dir.join("Sysfont-Regular.ttf"), dir.join("Sub/Sysfont-Bold.TTF")] { + let mut bytes = std::fs::read(&path).unwrap(); + for (from, to) in [ + (b"SourceSans3".to_vec(), b"SysfontSans".to_vec()), + ("SourceSans3".encode_utf16().flat_map(u16::to_be_bytes).collect(), "SysfontSans".encode_utf16().flat_map(u16::to_be_bytes).collect()), + ] { + let mut offset = 0; + while let Some(at) = bytes[offset..].windows(from.len()).position(|w| w == from) { + bytes[offset + at..offset + at + to.len()].copy_from_slice(&to); + offset += at + to.len(); + } + } + std::fs::write(path, bytes).unwrap(); + } + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert!(!db.is_loaded("SysfontSans-Bold")); + assert!(db.has_family("SysfontSans-Bold")); + let face = db.face("SysfontSans-Bold", "Regular"); + assert_eq!((face.family.as_str(), face.style.as_str()), (FAMILY, "Bold")); + assert_eq!(db.face("sysfontsans-bold", "Italic").id(), face.id()); + assert_eq!(db.face(FAMILY, "Regular").style, "Regular"); + assert!(!has(&db.families(), "SysfontSans-Bold")); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index eb6ffad0a..646f226d1 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (installed static fonts resolve by their exact PostScript names; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -155,10 +155,13 @@ No overall parity estimate changes from these probes. | Large real-world documents (500+ pages, many pictures) | not measured | | gap | | Scrolling a 1,096-page document that is one 5,300-row table (#811) | ~2.4 ms idle, ~4.5 ms median scroll frame (was ~600 ms); opening still lays out for ~4–8 s | not measured | measured 2026-10-12, release, headless (`tests_perf::big_table_scroll_bench`) | +Installed static fonts also resolve by their OpenType PostScript name (name ID 6), retaining the named face rather than substituting an unrelated family. Missing faces still use the existing fallback rules. A local Best for printing control from Word for Mac 16.113.2 (build 26092012) retained the directly requested Cambria-Bold face. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |