From db0affc0d30e8fd006b18c5f280d8852b65d1de0 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Sun, 18 Oct 2026 08:19:40 +0100 Subject: [PATCH] Recognize the Symbol PCL6 font alias (#2521) Co-authored-by: Brandon Thomas --- crates/doc/src/symbol_fonts.rs | 17 ++++++++++++++++- crates/docx/tests/fixtures.rs | 13 +++++++++++++ crates/fonts/src/word.rs | 17 ++++++++++++++++- crates/layout/src/para.rs | 25 ++++++++++++++++++++++++- crates/layout/src/tests.rs | 11 +++++++++++ docs/layout-parity.md | 5 +++-- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/symbol_fonts.rs b/crates/doc/src/symbol_fonts.rs index 14c06647c..775de2773 100644 --- a/crates/doc/src/symbol_fonts.rs +++ b/crates/doc/src/symbol_fonts.rs @@ -22,7 +22,7 @@ /// The symbol fonts this module knows, by lowercase family name. fn table(family: &str) -> Option<&'static [u32; 224]> { let f = family.trim(); - if f.eq_ignore_ascii_case("symbol") { + if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { Some(&SYMBOL) } else if f.eq_ignore_ascii_case("wingdings") { Some(&WINGDINGS) @@ -243,6 +243,21 @@ mod tests { } } + #[test] + fn pcl6_symbol_encoding() { + for family in ["Symbol (PCL6)", " symbol (pcl6) "] { + assert!(is_symbol_font(family)); + for code in 0x20..=0xFF { + let c = char::from_u32(code).unwrap(); + assert_eq!(symbol_char(family, c), symbol_char("Symbol", c)); + let pua = char::from_u32(0xF000 + code).unwrap(); + assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua)); + } + assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥')); + } + assert!(!is_symbol_font("Symbol (unknown)")); + } + #[test] fn symbol_uses_the_adobe_symbol_encoding() { assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α')); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2c36a3bb4..b653b953e 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -236,6 +236,19 @@ fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() { assert_eq!(q[0].shown_text(), p[0].shown_text()); } +#[test] +fn pcl6_symbol_roundtrip() { + let body = r#""#; + let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + for doc in [&d, &again] { + let p = paras(doc)[0]; + assert_eq!(p.text, "\u{F0B3}"); + assert_eq!(p.props_of_char(0).font.as_deref(), Some("Symbol (PCL6)")); + assert_eq!(p.shown_text(), "≥"); + } +} + #[test] fn alternate_content_choice_and_fallback() { let body = r#" diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 18daef4a1..e3e918736 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -227,7 +227,8 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { return *r; } } - let base = charset_base_family(family); + // Printer-driver Symbol aliases use the same encoding as Symbol. + let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; let (fam, substituted) = if db.has_family(family) { (family.to_string(), false) } else if base != family && db.has_family(base) { @@ -325,6 +326,20 @@ mod tests { assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman")); } + #[test] + fn pcl6_symbol_resolves_as_symbol() { + let actual = resolve("Symbol (PCL6)", false, false); + let db = FontDb::global(); + if db.has_family("Symbol (PCL6)") { + assert_eq!(actual.face.id(), db.face("Symbol (PCL6)", "Regular").id()); + assert!(!actual.substituted); + return; + } + let expected = resolve("Symbol", false, false); + assert_eq!(actual.face.id(), expected.face.id()); + assert_eq!(actual.substituted, expected.substituted); + } + #[test] fn symbol_fonts_substitute_symbol_faces() { for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 6b1ea46fd..101d1c8ca 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -674,7 +674,15 @@ const MISSING_ROMAN_FONT: &str = "Cambria"; 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 } + if roman + && !wordcraft_doc::symbol_fonts::is_symbol_font(font) + && !db.has_family(font) + && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + { + MISSING_ROMAN_FONT + } else { + font + } } impl<'a> Builder<'a> { @@ -2796,3 +2804,18 @@ mod label_spacing_tests { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn symbols_ignore_roman_hint() { + let mut d = Document::new(); + d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()]; + for family in ["Symbol (PCL6)", "Wingdings"] { + assert_eq!(lookup_family(&d, family), family); + } + assert_eq!(lookup_family(&d, "No Such Roman Font"), MISSING_ROMAN_FONT); + } +} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d6865b0d9..651a9c445 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1043,6 +1043,17 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() { assert_eq!(label.glyphs.len(), 1); } +#[test] +fn pcl6_symbol_draws_greater_equal() { + let mut d = Document::from_text("\u{F0B3}"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 3), &|c| c.font = Some("Symbol (PCL6)".into())).unwrap(); + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let text: String = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect(); + assert_eq!(text, "≥"); + assert_eq!(d.plain_text(StoryRef::Body), "\u{F0B3}"); +} + #[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 7a9e42b9c..be72d7c1c 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-16 · **Change:** trivial (exact full font names select their installed face; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; 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, @@ -26,7 +26,7 @@ font can't match Word's metrics. | 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 | | 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, 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 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 | 0 | | 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` | — | @@ -161,6 +161,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| | 2026-10-16 | trivial | Exact OpenType full names (name ID4) select installed static faces after family names; variable-instance aliases remain unsupported | +| 2026-10-16 | trivial | Symbol (PCL6) uses the existing Symbol mapping and Symbol face/fallback despite stale Roman font metadata; stored codes/fonts stay unchanged | | 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. |