mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
fe9d560b49
commit
74a279d5f2
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user