From 74a279d5f211c935e4cea67f8b72c37e419fc230 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:31:42 +0100 Subject: [PATCH] Fonts: a missing roman font is drawn in Cambria, as in Word (#1613) WordCraft chose a substitute for a font that isn't installed from its name alone, so an unknown brand font always came out sans. Word reads the font table instead: measured with the same missing font, it draws it in Cambria when w:family is roman and in Calibri when it is swiss or auto. A report whose font table calls its brand font roman got a sans footer where Word draws serif. The font table's roman fonts are read into the settings, and layout looks a missing one up as Cambria (Caladea where Cambria isn't installed). The other classes keep the existing substitutes, which already start with Calibri's metric clone. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 +++++ crates/docx/src/read/mod.rs | 18 ++++++++++++++++++ crates/docx/tests/fixtures.rs | 9 +++++++++ crates/layout/src/para.rs | 17 ++++++++++++++--- crates/layout/src/tests.rs | 21 +++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 6 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d2246ee6b..775c05284 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -494,6 +494,10 @@ pub struct Settings { /// 2013 and later, which places a table's border at the margin rather than its text and lets /// justified lines shrink their spaces to fit more text. pub compat_mode: u32, + /// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word + /// draws one that isn't installed in a serif face. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roman_fonts: Vec, /// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space /// after plus the second's space before, not the larger of the two. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -632,6 +636,7 @@ impl Default for Settings { protect_formatting: false, protect_hash: Vec::new(), compat_mode: COMPAT_MODE_CURRENT, + roman_fonts: Vec::new(), sum_paragraph_spacing: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 35db2cb93..0843164a6 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -33,6 +33,10 @@ use crate::xml::El; pub(crate) use props::PropCtx; use story::StoryCtx; +/// Most fonts read from a font table, and the longest name (hostile files). +const MAX_TABLE_FONTS: usize = 2000; +const MAX_FONT_NAME: usize = 256; + /// Most parts (headers, notes, comments, text boxes) we create from one file. const MAX_PARTS: usize = 50_000; /// Deepest chain of headers/footers whose own sections reference further headers/footers. @@ -171,6 +175,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { // The font table and the fonts embedded through it ride along as opaque parts. if let Some(p) = part(rt::FONT_TABLE) { r.keep_part_tree(&p); + r.read_font_families(&p); } if let Some(p) = part(rt::THEME) { r.read_theme(&p); @@ -581,6 +586,19 @@ impl Reader<'_> { } } + /// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`). + fn read_font_families(&mut self, path: &str) { + let Ok(Some(table)) = self.xml(path) else { return }; + self.doc.settings.roman_fonts = table + .children("w:font") + .filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman")) + .filter_map(|f| f.attr("w:name")) + .filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME) + .take(MAX_TABLE_FONTS) + .map(str::to_string) + .collect(); + } + fn xml(&self, path: &str) -> Result, DocxError> { self.pkg.xml(path) } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2b1a6f4e2..d61fbcb4a 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,15 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// The font table's family classes: Word draws a missing roman font in a serif face. +#[test] +fn roman_fonts_are_read_from_the_font_table() { + let fonts = r#""#; + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/fontTable.xml", fonts)])).unwrap(); + assert_eq!(d.settings.roman_fonts, ["Brand Serif"]); +} + /// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. #[test] fn html_auto_spacing_off_is_read_and_kept() { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7d119b177..f9efa11b9 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -666,6 +666,17 @@ fn east_asian(c: char) -> bool { crate::kinsoku::is_east_asian(c) || matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF) } +/// Word draws a font that isn't installed in Cambria when the document's font table classes it +/// as roman (measured in Word; Calibri otherwise, which our own substitutes already match). +const MISSING_ROMAN_FONT: &str = "Cambria"; + +/// The family to look `font` up as: [`MISSING_ROMAN_FONT`] for a missing roman font, else `font`. +fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str { + let db = wordcraft_fonts::FontDb::global(); + let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font)); + if roman && !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) { MISSING_ROMAN_FONT } else { font } +} + impl<'a> Builder<'a> { /// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one /// char out. `rtl` is the shaping direction; in Context mode `context` (from @@ -678,7 +689,7 @@ impl<'a> Builder<'a> { /// Style index for a resolved char in `face` (caps-scaled `size` override for small caps). fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u32 { - let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); + let r = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic); let face = face_override.unwrap_or(r.face); // The display list reads every run's format (link target, strike kind, underline colour, // baseline shift…) from its StyleRun, so runs share one only when their formats are equal. @@ -744,12 +755,12 @@ impl<'a> Builder<'a> { // Complex-script characters (Persian, Arabic…) use the run's complex-script font, size, // bold and italic. let rc_cs: Option> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex())); - let resolved = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); + let resolved = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic); let primary = resolved.face; // Symbol-font characters are laid out as their Unicode equivalents. let sym = SymbolRun::of(&rc.font, &resolved); let show = |c: char| sym.map_or(c, |m| m.show(c)); - let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face); + let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &r.font), r.bold, r.italic).face); // Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when // it is installed and has them. let primary_ea = rc diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7bed4c865..0616e41af 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,27 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn a_missing_roman_font_is_drawn_as_word_draws_it() { + // Measured in Word: a font that isn't installed is drawn in Cambria when the font table calls + // it roman, in Calibri otherwise. + let face = |roman: bool| { + let mut d = Document::from_text("Serif"); + if roman { + d.settings.roman_fonts = vec!["No Such Brand Font".into()]; + } + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.runs[0].props.font = Some("No Such Brand Font".into()); + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!("no text") }; + let first = para.clusters.first().expect("a cluster"); + para.styles[first.style as usize].face + }; + let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face; + assert_eq!(face(true), cambria); + assert_ne!(face(false), cambria); +} + #[test] fn an_empty_section_mark_stays_on_a_full_page() { use wordcraft_doc::section::SectionProps; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 143502d09..347b1be2c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (missing roman fonts drawn in Cambria; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -195,6 +195,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A missing font the font table classes as roman is drawn in Cambria, as measured in Word (Calibri for the other classes, as before) | | 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | | 2026-10-14 | trivial | Right triangles (`rtTriangle`) keep their right angle at the bottom left instead of becoming isosceles triangles, and save back as `rtTriangle` | | 2026-10-13 | trivial | Section `w:vAlign="both"` reads as Justified and writes back as `both` (#1320) |