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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fin Warman
2026-10-14 20:31:42 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent fe9d560b49
commit 74a279d5f2
6 changed files with 69 additions and 4 deletions
+5
View File
@@ -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<String>,
/// `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,
+18
View File
@@ -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<Document, DocxError> {
// 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<Option<El>, DocxError> {
self.pkg.xml(path)
}
+9
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Brand Serif"><w:family w:val="roman"/></w:font><w:font w:name="Brand Sans"><w:family w:val="swiss"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &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() {
+14 -3
View File
@@ -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<ResolvedChar>, face_override: Option<FaceRef>, 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<Arc<ResolvedChar>> = 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
+21
View File
@@ -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;
+2 -1
View File
@@ -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) |