From e60c69977516bb812b139d605cedc1bb21324b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Sat, 17 Oct 2026 13:30:45 +0300 Subject: [PATCH 1/5] Layout: a Symbol bullet's line keeps Symbol's height without the font Word sizes a line by the fonts in it, and a bullet in Symbol (Word's default, U+F0B7) has a taller ascent than Calibri or Times New Roman. Without Symbol (Linux, macOS without Office, the web build) WordCraft draws the bullet from another face and used that face's height, so every bulleted line was shorter than Word's: 14.5 pt against 15.1 pt in Calibri 11 pt at 1.08 spacing, 14.9 against 15.7 in Times New Roman 12 pt. symbol_font_metrics gives Symbol's and Wingdings' ascent and descent as Word's PDFs state them, and a run in one of them that isn't installed takes them. The label's ascent now comes from its style, so the existing rule (the label's extra ascent added unscaled) sees them. Measured in Word 2016: Calibri 11 / Times 12 bullets 15.08 / 15.72 pt, WordCraft now 15.07 / 15.75 pt. On 251 real documents against Word PDFs, page breaks at the same word 45.4% -> 50.1%. --- crates/fonts/src/word.rs | 12 ++++++++++++ crates/layout/src/para.rs | 9 ++++++--- crates/layout/src/tests.rs | 27 +++++++++++++++++++++++++++ docs/layout-parity.md | 5 +++-- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 18daef4a1..a86e5745d 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -100,6 +100,18 @@ fn substitutes(family: &str) -> &'static [&'static str] { } } +/// Ascent and descent (em) of Word's symbol fonts, which aren't open fonts. Their characters are +/// drawn from other faces (`wordcraft_doc::symbol_fonts`), but Word sizes a line by the font +/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's +/// PDFs give the fonts (`/Ascent`, `/Descent`). +pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { + match family.trim().to_ascii_lowercase().as_str() { + "symbol" => Some((1.005, 0.216)), + "wingdings" => Some((0.899, 0.205)), + _ => None, + } +} + /// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`, /// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for /// the font itself, and old Russian and Central European documents name them. Other names are diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 6b1ea46fd..d77119095 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -715,7 +715,11 @@ impl<'a> Builder<'a> { } } let size = rc.draw_size() * if small { 0.8 } else { 1.0 }; - let (a, d) = wordcraft_fonts::word::line_metrics(&face); + let (a, d) = match wordcraft_fonts::word::symbol_font_metrics(&rc.font) { + // A symbol font that isn't installed keeps its own height, as in Word. + Some((a, d)) if !wordcraft_fonts::FontDb::global().has_family(&rc.font) => (a * face.upem, d * face.upem), + _ => wordcraft_fonts::word::line_metrics(&face), + }; let k = size as f64 / face.upem.max(1.0); // A fallback face (letters the requested font lacks) is emboldened or slanted when it has // no bold or italic of its own. @@ -1533,8 +1537,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { for c in &cl { style = c.style; if let Some(st) = b.styles.get(style as usize) { - let a = wordcraft_fonts::word::line_metrics(&st.face).0 * st.size as f64 / st.face.upem.max(1.0); - ascent = ascent.max(a as f32 + st.shift.max(0.0)); + ascent = ascent.max(st.ascent + st.shift.max(0.0)); } let g0 = glyphs.len(); for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d6865b0d9..faff1692a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1043,6 +1043,33 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() { assert_eq!(label.glyphs.len(), 1); } +#[test] +fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() { + // Measured in Word: a Symbol bullet makes its line taller than the text alone (Calibri 11 pt + // at 1.08 spacing: 15.1 pt, not 14.5 pt), the extra ascent added unscaled. Symbol isn't an open + // font; without it the bullet is drawn from another face but its line keeps Symbol's height. + if !wordcraft_fonts::word::resolve("Symbol", false, false).substituted { + return; + } + let first_line = |label_font: &str| { + let mut d = Document::from_text("one"); + let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.chr.font = Some(label_font.into()); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + 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 size = para.styles.iter().map(|s| s.size).fold(0.0f32, f32::max); + (para.label.as_ref().map_or(0.0, |lab| lab.ascent), size) + }; + let (ascent, size) = first_line("Symbol"); + let want = (1.005 * f64::from(size)) as f32; + assert!((ascent - want).abs() < 0.01, "{ascent} vs {want}"); +} + #[test] fn wingdings_text_draws_and_reads_as_its_unicode_symbols() { // Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes diff --git a/docs/layout-parity.md b/docs/layout-parity.md index eb6ffad0a..a3ca40a41 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-17 · **Change:** trivial (symbol fonts keep their line height without the font; 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, @@ -25,7 +25,7 @@ font can't match Word's metrics. | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | | Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | -| Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — | +| Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font` | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | A Symbol or Wingdings run without the font keeps that font's line height: a Symbol bullet's line is 15.1 pt in Calibri 11 pt at 1.08 spacing, as in Word (was 14.5 pt). On a 251-document corpus checked against Word PDFs, page breaks at the same word 45.4% → 50.1% | | 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. | From e6e007d018c1cad3df8237df14ccf38e098f2beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Tue, 20 Oct 2026 05:18:11 +0300 Subject: [PATCH 2/5] Symbol (PCL6) keeps Symbol's line metrics; restore the STYLEREF row in layout-parity --- crates/fonts/src/word.rs | 8 +++++++- docs/layout-parity.md | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index ac0fd5e4b..5c66470b0 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -133,7 +133,7 @@ fn substitutes(family: &str) -> &'static [&'static str] { /// PDFs give the fonts (`/Ascent`, `/Descent`). pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { match family.trim().to_ascii_lowercase().as_str() { - "symbol" => Some((1.005, 0.216)), + "symbol" | "symbol (pcl6)" => Some((1.005, 0.216)), "wingdings" => Some((0.899, 0.205)), _ => None, } @@ -462,6 +462,12 @@ mod tests { assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman")); } + #[test] + fn pcl6_symbol_has_symbols_line_metrics() { + assert_eq!(symbol_font_metrics("Symbol (PCL6)"), symbol_font_metrics("Symbol")); + assert_eq!(symbol_font_metrics("Arial"), None); + } + #[test] fn pcl6_symbol_resolves_as_symbol() { let actual = resolve("Symbol (PCL6)", false, false); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 84e3dc381..845f43d2b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -80,8 +80,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. Most other legacy compat options remain ignored | 10–20 | -| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; ` - \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | +| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | From 912441262e0da34d8fae1d32ac7048548975b42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Tue, 20 Oct 2026 06:11:47 +0300 Subject: [PATCH 3/5] Layout tests: a Symbol bullet's line is the text's height plus its extra ascent --- crates/layout/src/tests.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d7b4a640d..2d5360622 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1224,23 +1224,33 @@ fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() { if !wordcraft_fonts::word::resolve("Symbol", false, false).substituted { return; } - let first_line = |label_font: &str| { + // The label's ascent, the text size, and the first line's height and text ascent; no label + // font = no bullet. + let first_line = |label_font: Option<&str>| { let mut d = Document::from_text("one"); - let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); - for a in &mut d.numbering.abstracts { - if let Some(l) = a.levels.first_mut() { - l.chr.font = Some(label_font.into()); + if let Some(label_font) = label_font { + let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.chr.font = Some(label_font.into()); + } } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })) + .unwrap(); } - d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); 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 size = para.styles.iter().map(|s| s.size).fold(0.0f32, f32::max); - (para.label.as_ref().map_or(0.0, |lab| lab.ascent), size) + (para.label.as_ref().map_or(0.0, |lab| lab.ascent), size, para.lines[0].height, para.lines[0].text_asc) }; - let (ascent, size) = first_line("Symbol"); + let (ascent, size, height, text_asc) = first_line(Some("Symbol")); let want = (1.005 * f64::from(size)) as f32; assert!((ascent - want).abs() < 0.01, "{ascent} vs {want}"); + // The line is the text's own height plus the bullet's extra ascent, unscaled. + let (_, _, plain, _) = first_line(None); + let extra = ascent - text_asc; + assert!(extra > 0.0, "Symbol's ascent {ascent} is above the text's {text_asc}"); + assert!((height - (plain + extra)).abs() < 0.01, "{height} vs {plain} + {extra}"); } #[test] From 66ca8745c0defbfe186e2d2119b88f0dc5b5a0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Tue, 20 Oct 2026 07:16:33 +0300 Subject: [PATCH 4/5] Layout: a Symbol (PCL6) bullet drawn by an installed Symbol takes Symbol's own height The fallback Symbol metrics applied whenever the raw family name wasn't installed, so Symbol (PCL6) resolved to an installed Symbol still took them. Use the resolution's substituted state instead. --- crates/layout/src/para.rs | 6 +++-- crates/layout/src/tests.rs | 49 ++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index af346e782..6ab9df41b 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -856,8 +856,10 @@ impl<'a> Builder<'a> { } let size = rc.draw_size() * if small { 0.8 } else { 1.0 }; let (a, d) = match wordcraft_fonts::word::symbol_font_metrics(&rc.font) { - // A symbol font that isn't installed keeps its own height, as in Word. - Some((a, d)) if !wordcraft_fonts::FontDb::global().has_family(&rc.font) => (a * face.upem, d * face.upem), + // A symbol font that isn't installed keeps its own height, as in Word. An alias drawn + // with an installed face (`Symbol (PCL6)` by Symbol) is not substituted and takes that + // face's height. + Some((a, d)) if r.substituted => (a * face.upem, d * face.upem), _ => wordcraft_fonts::word::line_metrics(&face), }; let k = size as f64 / face.upem.max(1.0); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2d5360622..119357317 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1216,6 +1216,25 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() { assert_eq!(label.glyphs.len(), 1); } +/// The label's ascent, the text size, and the first line's height and text ascent of a one-line +/// bulleted paragraph whose bullet is in `label_font`; no label font = no bullet. +fn bullet_first_line(label_font: Option<&str>) -> (f32, f32, f32, f32) { + let mut d = Document::from_text("one"); + if let Some(label_font) = label_font { + let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.chr.font = Some(label_font.into()); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + } + 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 size = para.styles.iter().map(|s| s.size).fold(0.0f32, f32::max); + (para.label.as_ref().map_or(0.0, |lab| lab.ascent), size, para.lines[0].height, para.lines[0].text_asc) +} + #[test] fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() { // Measured in Word: a Symbol bullet makes its line taller than the text alone (Calibri 11 pt @@ -1224,35 +1243,23 @@ fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() { if !wordcraft_fonts::word::resolve("Symbol", false, false).substituted { return; } - // The label's ascent, the text size, and the first line's height and text ascent; no label - // font = no bullet. - let first_line = |label_font: Option<&str>| { - let mut d = Document::from_text("one"); - if let Some(label_font) = label_font { - let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); - for a in &mut d.numbering.abstracts { - if let Some(l) = a.levels.first_mut() { - l.chr.font = Some(label_font.into()); - } - } - d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })) - .unwrap(); - } - 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 size = para.styles.iter().map(|s| s.size).fold(0.0f32, f32::max); - (para.label.as_ref().map_or(0.0, |lab| lab.ascent), size, para.lines[0].height, para.lines[0].text_asc) - }; - let (ascent, size, height, text_asc) = first_line(Some("Symbol")); + let (ascent, size, height, text_asc) = bullet_first_line(Some("Symbol")); let want = (1.005 * f64::from(size)) as f32; assert!((ascent - want).abs() < 0.01, "{ascent} vs {want}"); // The line is the text's own height plus the bullet's extra ascent, unscaled. - let (_, _, plain, _) = first_line(None); + let (_, _, plain, _) = bullet_first_line(None); let extra = ascent - text_asc; assert!(extra > 0.0, "Symbol's ascent {ascent} is above the text's {text_asc}"); assert!((height - (plain + extra)).abs() < 0.01, "{height} vs {plain} + {extra}"); } +#[test] +fn a_pcl6_symbol_bullet_sizes_its_line_as_symbol() { + // `Symbol (PCL6)` is drawn with Symbol: an installed Symbol gives both their own face's height, + // a missing one gives both Symbol's. + assert_eq!(bullet_first_line(Some("Symbol (PCL6)")), bullet_first_line(Some("Symbol"))); +} + #[test] fn pcl6_symbol_draws_greater_equal() { let mut d = Document::from_text("\u{F0B3}"); From 7f2f45743a3cb1b0d6bd064a1fcd58584c5e9f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Tue, 20 Oct 2026 12:14:18 +0300 Subject: [PATCH 5/5] Layout: Apple's one-em Symbol takes Windows Symbol's line height, as in Word for Mac Word for Mac spaces Symbol lines at 1.222 em (measured at 11 and 20 pt) even with Apple's Symbol installed, whose own line is one em. Symbol's metrics now apply when the resolved face is one em tall as well as when the font is substituted (fonts::word::symbol_line). --- crates/fonts/src/word.rs | 29 +++++++++++++++++++++++++++++ crates/layout/src/para.rs | 11 +++++------ docs/layout-parity.md | 2 +- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5c66470b0..7831d5823 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -139,6 +139,20 @@ pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { } } +/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the +/// resolved face's own: the font isn't installed (`resolved` is a substitute), or the installed +/// face is one em tall, as Apple's Symbol is (hhea 0.701 / -0.299, no gap). Word for Mac spaces +/// such lines like Windows' Symbol: 1.222 em at 11 and 20 pt on synthetic documents (#2681). +pub fn symbol_line(family: &str, resolved: &Resolved) -> Option<(f64, f64)> { + let m = symbol_font_metrics(family)?; + (resolved.substituted || one_em(line_metrics(&resolved.face), resolved.face.upem)).then_some(m) +} + +/// Is a line's (ascent, descent) one `upem` em tall? +fn one_em((ascent, descent): (f64, f64), upem: f64) -> bool { + upem > 0.0 && ((ascent + descent) / upem - 1.0).abs() < 0.005 +} + /// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`, /// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for /// the font itself, and old Russian and Central European documents name them. Other names are @@ -468,6 +482,21 @@ mod tests { assert_eq!(symbol_font_metrics("Arial"), None); } + #[test] + fn a_one_em_symbol_face_takes_windows_symbols_line() { + // Apple's Symbol (2048 units): hhea 1436 / -612, no gap, one em; Windows' Symbol line is 1.221 em. + assert!(one_em(hhea_line(1436, -612, 0), 2048.0)); + assert!(!one_em((1005.0, 216.0), 1000.0)); + assert!(!one_em((899.0, 211.0), 1000.0)); + assert!(!one_em((0.0, 0.0), 0.0)); + let arial = resolve("Arial", false, false); + assert_eq!(symbol_line("Arial", &arial), None); + let symbol = resolve("Symbol", false, false); + if symbol.substituted { + assert_eq!(symbol_line("Symbol", &symbol), symbol_font_metrics("Symbol")); + } + } + #[test] fn pcl6_symbol_resolves_as_symbol() { let actual = resolve("Symbol (PCL6)", false, false); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 6ab9df41b..e250d31c2 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -855,12 +855,11 @@ impl<'a> Builder<'a> { } } let size = rc.draw_size() * if small { 0.8 } else { 1.0 }; - let (a, d) = match wordcraft_fonts::word::symbol_font_metrics(&rc.font) { - // A symbol font that isn't installed keeps its own height, as in Word. An alias drawn - // with an installed face (`Symbol (PCL6)` by Symbol) is not substituted and takes that - // face's height. - Some((a, d)) if r.substituted => (a * face.upem, d * face.upem), - _ => wordcraft_fonts::word::line_metrics(&face), + // A symbol font that isn't installed, or is installed one em tall (Apple's Symbol), takes + // the height Word gives it. + let (a, d) = match wordcraft_fonts::word::symbol_line(&rc.font, &r) { + Some((a, d)) => (a * face.upem, d * face.upem), + None => wordcraft_fonts::word::line_metrics(&face), }; let k = size as f64 / face.upem.max(1.0); // A fallback face (letters the requested font lacks) is emboldened or slanted when it has diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 845f43d2b..ae3aac4dc 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -37,7 +37,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | | Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Full font names in document font requests | ✅ | Exact English (or first available) OpenType name ID4 lookup in loaded faces and the native lazy catalog; family and legacy-family matches keep priority. No style-suffix guessing; named variable-instance aliases remain unsupported | 0 | -| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font` | 0 | +| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 | | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 |