From 0f9dc5c6ae4de90fea394db8c6ea6e6deed7b91c Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 17:26:21 +0200 Subject: [PATCH] #776: draw Wingdings, Wingdings 2/3, Webdings and Symbol text as Unicode symbols (#876) Word's symbol fonts are proprietary and never bundled, so text set in them showed the substitute's letters. New wordcraft_doc::symbol_fonts maps every code (stored as U+F020-U+F0FF, e.g. w:sym, or as a plain 8-bit code) to its Unicode equivalent: Symbol from Unicode's Adobe symbol.txt, the Wingdings and Webdings sets from Unicode L2/12-368 (WG2 N4384), checked against UCD. Layout shapes the mapped characters (fallback faces draw them); PDF text, copy as text and TXT export read as the symbols; the stored text and docx round trips are unchanged. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/doc/src/edit.rs | 7 + crates/doc/src/lib.rs | 19 ++- crates/doc/src/para.rs | 24 +++ crates/doc/src/symbol_fonts.rs | 296 +++++++++++++++++++++++++++++++++ crates/docx/tests/fixtures.rs | 25 +++ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/paste.rs | 4 +- crates/engine/src/tests.rs | 16 ++ crates/fonts/src/word.rs | 48 +----- crates/formats/src/model.rs | 16 ++ crates/formats/src/tests.rs | 8 + crates/formats/src/txt.rs | 2 +- crates/layout/src/display.rs | 8 +- crates/layout/src/para.rs | 92 +++++++--- crates/layout/src/tests.rs | 37 ++++- docs/file-format-parity.md | 3 +- docs/layout-parity.md | 4 +- 18 files changed, 540 insertions(+), 75 deletions(-) create mode 100644 crates/doc/src/symbol_fonts.rs diff --git a/ROADMAP.md b/ROADMAP.md index a1d48a1..7e3a4fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (WMF and EMF pictures can be inserted, #802; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -169,6 +169,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents | | 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty | | 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` | +| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| @@ -280,6 +281,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) | | 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) | | 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents | +| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) | | 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) | | 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) | | 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 12de09d..86c334d 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -27,6 +27,13 @@ impl Fragment { d.body = self.blocks.iter().cloned().map(Arc::new).collect(); d.plain_text(crate::StoryRef::Body) } + /// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters + /// (Wingdings…) as their Unicode equivalents. + pub fn clipboard_text(&self) -> String { + let mut d = Document::new(); + d.body = self.blocks.iter().cloned().map(Arc::new).collect(); + d.shown_text(crate::StoryRef::Body) + } pub fn from_text(s: &str) -> Fragment { let lines: Vec<&str> = s.split('\n').collect(); Fragment { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 07d2e37..4d710ad 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -38,6 +38,7 @@ pub mod resolve; pub mod section; pub mod smart_art; pub mod styles; +pub mod symbol_fonts; pub mod table; pub mod wordart; pub mod wrap; @@ -801,9 +802,19 @@ impl Document { /// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`). pub fn plain_text(&self, s: StoryRef) -> String { + self.story_text(s, false) + } + + /// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their + /// Unicode equivalents ([`Paragraph::shown_text`]). + pub fn shown_text(&self, s: StoryRef) -> String { + self.story_text(s, true) + } + + fn story_text(&self, s: StoryRef, shown: bool) -> String { let mut out = String::new(); if let Some(bl) = self.story(s) { - text_of(bl, &mut out, 0); + text_of(bl, &mut out, 0, shown); } if out.ends_with('\n') { out.pop(); @@ -1276,14 +1287,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { } } -fn text_of(bl: &Blocks, out: &mut String, depth: usize) { +fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) { if depth > 16 { return; } for b in bl { match &**b { Block::Para(p) => { - out.push_str(&p.plain_text()); + out.push_str(&if shown { p.shown_text() } else { p.plain_text() }); out.push('\n'); } Block::Table(t) => { @@ -1291,7 +1302,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize) { let mut cells = Vec::new(); for c in &row.cells { let mut s = String::new(); - text_of(&c.blocks, &mut s, depth + 1); + text_of(&c.blocks, &mut s, depth + 1, shown); cells.push(s.trim_end_matches('\n').replace('\n', " ")); } out.push_str(&cells.join("\t")); diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 287f12a..b1b0692 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -831,6 +831,30 @@ impl Paragraph { } out } + /// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font + /// (Symbol, Wingdings…) become their standard Unicode equivalents + /// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is + /// unchanged. + pub fn shown_text(&self) -> String { + let mut out = String::with_capacity(self.text.len()); + let mut k = 0; + let mut runs = self.run_ranges().peekable(); + for (i, c) in self.text.char_indices() { + while runs.peek().is_some_and(|(r, _)| r.end <= i) { + runs.next(); + } + if c == OBJ { + if let Some(o) = self.objects.get(k) { + out.push_str(o.plain_text()); + } + k += 1; + continue; + } + let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref()); + out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c)); + } + out + } /// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted. pub fn final_text(&self) -> String { self.text_without(&self.deleted_ranges()) diff --git a/crates/doc/src/symbol_fonts.rs b/crates/doc/src/symbol_fonts.rs new file mode 100644 index 0000000..14c0664 --- /dev/null +++ b/crates/doc/src/symbol_fonts.rs @@ -0,0 +1,296 @@ +//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to +//! standard Unicode characters, so text set in them shows the intended symbols when the font +//! isn't installed (they are proprietary and never bundled) and exports as real characters. +//! +//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own +//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code +//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text +//! never changes: the mapping is for display, layout and plain-text output only. +//! +//! Sources (Unicode's own published data; no font file was read): +//! - Symbol: the Adobe Symbol encoding table published by Unicode, +//! (codes it maps to the +//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU). +//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of +//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard, +//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052, +//! L2/11-344), checked against the Unicode Character Database names. The one character not +//! encoded (Wingdings 0xFF) has no entry. +//! +//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. + +/// 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") { + Some(&SYMBOL) + } else if f.eq_ignore_ascii_case("wingdings") { + Some(&WINGDINGS) + } else if f.eq_ignore_ascii_case("wingdings 2") { + Some(&WINGDINGS_2) + } else if f.eq_ignore_ascii_case("wingdings 3") { + Some(&WINGDINGS_3) + } else if f.eq_ignore_ascii_case("webdings") { + Some(&WEBDINGS) + } else { + None + } +} + +/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, +/// Webdings), whose characters [`symbol_char`] maps. +pub fn is_symbol_font(family: &str) -> bool { + table(family).is_some() +} + +/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the +/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD). +/// `None` for other fonts, other characters and codes with no Unicode equivalent. +pub fn symbol_char(family: &str, c: char) -> Option { + let t = table(family)?; + let u = u32::from(c); + let code = match u { + 0xF020..=0xF0FF => u - 0xF000, + // A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD). + 0xAD => return None, + 0x20..=0xFF => u, + _ => return None, + }; + let i = usize::try_from(code.checked_sub(0x20)?).ok()?; + t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32) +} + +/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode +/// equivalent; unchanged for other fonts. +pub fn map_text(family: &str, text: &str) -> String { + if !is_symbol_font(family) { + return text.to_string(); + } + text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect() +} + +const SYMBOL: [u32; 224] = [ + 0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20 + 0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28 + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30 + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38 + 0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40 + 0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48 + 0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50 + 0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58 + 0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60 + 0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68 + 0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70 + 0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98 + 0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0 + 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8 + 0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0 + 0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8 + 0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0 + 0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8 + 0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0 + 0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8 + 0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8 + 0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +const WEBDINGS: [u32; 224] = [ + 0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20 + 0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28 + 0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30 + 0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38 + 0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40 + 0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48 + 0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50 + 0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58 + 0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60 + 0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68 + 0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70 + 0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78 + 0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80 + 0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88 + 0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90 + 0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98 + 0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0 + 0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8 + 0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0 + 0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8 + 0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0 + 0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8 + 0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0 + 0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8 + 0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0 + 0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8 + 0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0 + 0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8 +]; + +const WINGDINGS: [u32; 224] = [ + 0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20 + 0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28 + 0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30 + 0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38 + 0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40 + 0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48 + 0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50 + 0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58 + 0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60 + 0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68 + 0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70 + 0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78 + 0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80 + 0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88 + 0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90 + 0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98 + 0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0 + 0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8 + 0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0 + 0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8 + 0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0 + 0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8 + 0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0 + 0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8 + 0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0 + 0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8 + 0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0 + 0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8 +]; + +const WINGDINGS_2: [u32; 224] = [ + 0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20 + 0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28 + 0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30 + 0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38 + 0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40 + 0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48 + 0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50 + 0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58 + 0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60 + 0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68 + 0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70 + 0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78 + 0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80 + 0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88 + 0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90 + 0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98 + 0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0 + 0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8 + 0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0 + 0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8 + 0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0 + 0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8 + 0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0 + 0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8 + 0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0 + 0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8 + 0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0 + 0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +const WINGDINGS_3: [u32; 224] = [ + 0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20 + 0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28 + 0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30 + 0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38 + 0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40 + 0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48 + 0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50 + 0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58 + 0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60 + 0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68 + 0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70 + 0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78 + 0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80 + 0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88 + 0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90 + 0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98 + 0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0 + 0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8 + 0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0 + 0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8 + 0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0 + 0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8 + 0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0 + 0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8 + 0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0 + 0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8 + 0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0 + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_storage_forms_map_alike() { + for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] { + assert!(is_symbol_font(f)); + for code in 0x20u32..=0xFF { + let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue }; + if code == 0xAD { + assert_eq!(symbol_char(f, plain), None); + continue; + } + assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}"); + } + } + } + + #[test] + fn symbol_uses_the_adobe_symbol_encoding() { + assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α')); + assert_eq!(symbol_char("symbol", 'a'), Some('α')); + assert_eq!(symbol_char("Symbol", 'm'), Some('μ')); + assert_eq!(symbol_char("Symbol", 'W'), Some('Ω')); + assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•')); + assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞')); + assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√')); + // The radical extender is private use in the Adobe table: no equivalent. + assert_eq!(symbol_char("Symbol", '\u{F060}'), None); + } + + #[test] + fn wingdings_codes() { + assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺')); + assert_eq!(symbol_char("Wingdings", 'J'), Some('☺')); + assert_eq!(symbol_char("Wingdings", 'L'), Some('☹')); + assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓')); + assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}')); + assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼')); + assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪')); + assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖')); + assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}')); + // The one character Unicode didn't encode. + assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None); + } + + #[test] + fn wingdings_2_3_and_webdings_codes() { + assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}')); + assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}')); + assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None); + assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠')); + assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None); + assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}')); + assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}')); + assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}')); + } + + #[test] + fn other_fonts_and_characters_are_left_alone() { + assert!(!is_symbol_font("Arial")); + assert_eq!(symbol_char("Arial", '\u{F04A}'), None); + assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None); + assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None); + assert_eq!(symbol_char("Wingdings", '\u{1F}'), None); + assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}"); + assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}"); + } +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 3b616b8..689f37d 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -178,6 +178,31 @@ fn hyperlink_runs_and_wrappers() { assert_eq!(p[1].plain_text(), "in block sdt"); } +#[test] +fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() { + // Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a + // run whose font is Wingdings; plus Symbol and Webdings. + let body = r#" + + Done + + J + +"#; + let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = paras(&d); + // Stored as Word stores it… + assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}"); + // …read (copied, exported as text) as the symbols shown. + assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}"); + assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}"); + // A round trip keeps the original characters and fonts. + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let q = paras(&again); + assert_eq!(q[0].plain_text(), p[0].plain_text()); + assert_eq!(q[0].shown_text(), p[0].shown_text()); +} + #[test] fn alternate_content_choice_and_fallback() { let body = r#" diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ebc8c0e..d239a5e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -159,7 +159,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult { return Ok(json!({"text": ""})); } let f = s.doc.copy_range(&a, &b); - s.clipboard_text = f.plain_text(); + s.clipboard_text = f.clipboard_text(); s.clip_history.push(&f, &s.clipboard_text); s.clipboard = Some(f); Ok(json!({"text": s.clipboard_text})) diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 4e23ae4..7b86234 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -66,7 +66,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option { let f = s.clipboard.as_ref()?; // System clipboard text that isn't our own copy means someone else copied since. match p::str(v, "text") { - Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None, + Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None, _ => Some(String::new()), } } @@ -194,7 +194,7 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option { pub fn paste_fragment(s: &mut Session, v: &Value) -> Result { let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n")); if let Some(f) = &s.clipboard - && text.as_ref().is_none_or(|t| f.plain_text() == *t) + && text.as_ref().is_none_or(|t| f.clipboard_text() == *t) && (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none())) { return Ok(f.clone()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed45073..a722ce9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -813,6 +813,22 @@ fn clipboard_round_trip() { assert_eq!(text(&s), "plain\ntext"); } +#[test] +fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "ok J\u{F0FC}"})); + s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "ok \u{263A}\u{2713}"); + // Pasting our own copy back (the system clipboard holds the mapped text) keeps the stored + // Wingdings characters, not the Unicode ones. + run(&mut s, "caret.docEnd", json!({})); + let clip = s.clipboard_text.clone(); + run(&mut s, "edit.paste", json!({"text": clip})); + assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}"); +} + fn bold_at(s: &Session, block: usize, off: usize) -> bool { let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off }; s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 66ef6a7..e8436a5 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -30,7 +30,9 @@ fn substitutes(family: &str) -> &'static [&'static str] { const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"]; const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"]; const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"]; - const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"]; + // Word's symbol fonts (Symbol, Wingdings…) are proprietary: their characters are drawn as their + // Unicode equivalents (`wordcraft_doc::symbol_fonts`) from these faces or any face that has them. + const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Noto Sans Symbols 2", "Source Sans 3"]; // Persian / Arabic document fonts. Naskh (book) faces such as B Nazanin, B Lotus, B Zar and // Traditional Arabic, then the sans faces (B Yekan, B Titr, IRANSans) people use for // headings and screens. Open fonts with full Persian coverage first. @@ -66,7 +68,7 @@ fn substitutes(family: &str) -> &'static [&'static str] { "arial" | "helvetica" | "arial nova" => ARIAL, "times new roman" | "times" => TIMES, "courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO, - "symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL, + "symbol" | "wingdings" | "wingdings 2" | "wingdings 3" | "segoe ui symbol" | "webdings" => SYMBOL, "cambria" | "cambria math" | "georgia" @@ -207,34 +209,6 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { r } -/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores -/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used -/// when that font isn't installed: a substitute draws something else, or nothing, at the -/// private-use code. `None` for other fonts and for codes not in the table. -pub fn symbol_font_char(family: &str, c: char) -> Option { - let code = u32::from(c).checked_sub(0xF000)?; - let u = match (family.to_ascii_lowercase().as_str(), code) { - // Adobe Symbol encoding. - ("symbol", 0xA7) => '\u{2663}', - ("symbol", 0xA8) => '\u{2666}', - ("symbol", 0xA9) => '\u{2665}', - ("symbol", 0xAA) => '\u{2660}', - ("symbol", 0xAE) => '\u{2192}', - ("symbol", 0xB7) => '\u{2022}', - ("symbol", 0xD7) => '\u{22C5}', - ("symbol", 0xE0) => '\u{25CA}', - // Wingdings characters common as list bullets. - ("wingdings", 0x6C) => '\u{25CF}', - ("wingdings", 0x6E) => '\u{25A0}', - ("wingdings", 0x76) => '\u{2756}', - ("wingdings", 0xA7) => '\u{25AA}', - ("wingdings", 0xD8) => '\u{27A2}', - ("wingdings", 0xFC) => '\u{2714}', - _ => return None, - }; - Some(u) -} - /// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent. fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) { (f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs()) @@ -307,16 +281,10 @@ mod tests { } #[test] - fn symbol_font_chars_map_to_unicode() { - assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}')); - assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}')); - assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}')); - assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}')); - // Same code, other font; unknown codes; ordinary characters. - assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None); - assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None); - assert_eq!(symbol_font_char("Symbol", 'a'), None); - assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None); + fn symbol_fonts_substitute_symbol_faces() { + for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] { + assert!(substitutes(f).contains(&"Noto Sans Symbols 2"), "{f}"); + } } #[test] diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 9ca5177..8e6a440 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -126,6 +126,22 @@ impl Para { } s } + /// [`Self::text`] as it reads on screen: text set in a symbol font (Symbol, Wingdings…) as + /// its Unicode equivalents ([`wordcraft_doc::symbol_fonts`]), for plain-text export. + pub fn shown_text(&self) -> String { + let mut s = String::new(); + for i in &self.inlines { + match i { + Inline::Text(t, f) => match f.font.as_deref() { + Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)), + _ => s.push_str(t), + }, + Inline::Equation { linear: t, .. } => s.push_str(t), + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} + } + } + s + } pub fn is_empty(&self) -> bool { self.inlines.iter().all(|i| matches!(i, Inline::Text(t, _) if t.is_empty())) } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 018ccc5..d4a8190 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -280,6 +280,14 @@ fn txt_round_trip() { assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€"); } +#[test] +fn txt_export_writes_symbol_font_text_as_unicode() { + let mut d = Document::from_text("Done J\u{F0FC}"); + d.format_range(&wordcraft_doc::Pos::body(0, 5), &wordcraft_doc::Pos::body(0, 9), &|c| c.font = Some("Wingdings".into())).unwrap(); + let out = export("txt", &d).unwrap().unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "Done \u{263A}\u{2713}"); +} + /// #569: a file with an extension we don't know opens as text when it is text (a playlist), and /// a binary file is told apart. #[test] diff --git a/crates/formats/src/txt.rs b/crates/formats/src/txt.rs index 0f85b3a..51ed4cd 100644 --- a/crates/formats/src/txt.rs +++ b/crates/formats/src/txt.rs @@ -102,7 +102,7 @@ fn blocks_text(blocks: &[FBlock], out: &mut Vec, lists: &mut ListCounter } None => lists.reset(), } - line.push_str(&p.text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], "")); + line.push_str(&p.shown_text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], "")); out.push(line); } FBlock::Table(t) => { diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index d9d3493..8a98d5a 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -720,7 +720,13 @@ fn lines( text.push_str(s); shown = Some(r); } else if let Some(p) = para { - text.push_str(p.text.get(c.start..c.end).unwrap_or("")); + let t = p.text.get(c.start..c.end).unwrap_or(""); + // Symbol-font characters (Wingdings…) read as the Unicode symbols drawn. + if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) { + text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t)); + } else { + text.push_str(t); + } } let b = text.len(); let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 281d33e..8ac9dd9 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -15,6 +15,53 @@ use wordcraft_fonts::FaceRef; use crate::NumeralMode; use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text}; +/// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) +/// shows its characters. Those fonts are proprietary and usually missing: a substitute has other +/// glyphs, or none, at their codes, so each character is drawn as its Unicode equivalent +/// ([`wordcraft_doc::symbol_fonts`]) from a face that has it. The text itself is unchanged. +#[derive(Clone, Copy)] +struct SymbolRun<'a> { + family: &'a str, + /// The symbol font itself is installed (`face` is it). + installed: bool, + face: FaceRef, +} + +impl<'a> SymbolRun<'a> { + fn of(family: &'a str, r: &wordcraft_fonts::word::Resolved) -> Option { + wordcraft_doc::symbol_fonts::is_symbol_font(family).then_some(SymbolRun { family, installed: !r.substituted, face: r.face }) + } + + /// The character drawn for `c`. + fn show(&self, c: char) -> char { + if self.installed { + if self.face.covers(c) { + return c; + } + // A plain 8-bit code in the installed font: its glyph sits at the private-use code. + if let Some(p) = (0x20..=0xFF).contains(&u32::from(c)).then(|| char::from_u32(0xF000 + u32::from(c))).flatten() + && self.face.covers(p) + { + return p; + } + } + let Some(u) = wordcraft_doc::symbol_fonts::symbol_char(self.family, c) else { return c }; + // A few list bullets have common look-alikes in far more fonts: use one when no face + // has the exact character. + let alt = match u { + '\u{26AB}' => Some('\u{25CF}'), + '\u{25FC}' => Some('\u{25A0}'), + '\u{2B9A}' => Some('\u{27A2}'), + _ => None, + }; + let has = |ch: char| self.face.covers(ch) || wordcraft_fonts::FontDb::global().fallback_for(ch, self.face.id()).is_some(); + match alt { + Some(a) if !has(u) && has(a) => a, + _ => u, + } + } +} + /// A face at a size with the formatting needed to draw it. #[derive(Clone, Debug)] pub struct StyleRun { @@ -657,7 +704,11 @@ 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 primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face; + let resolved = wordcraft_fonts::word::resolve(&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); // Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when // it is installed and has them. @@ -692,6 +743,8 @@ impl<'a> Builder<'a> { let mut segs: Vec<(usize, usize, SegKey)> = Vec::new(); for (i, c) in text.char_indices() { let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); + // A symbol-font character becomes its Unicode symbol (#776). + let c = show(c); // Displayed form (Word › Numeral): one char in, one char out. Coverage and the // fallback face are picked for the displayed char, so Hindi digits land in an // Arabic-capable face and Western digits in a Latin one. @@ -759,7 +812,16 @@ impl<'a> Builder<'a> { orig_of.push(sub.len()); } let shape_text = if substituted { mapped.as_str() } else { sub }; - let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c }; + let map = |c: char| { + let d = show(c); + if d != c { + d + } else if upper { + c.to_uppercase().next().unwrap_or(c) + } else { + c + } + }; // The shaper kerns by default; Word kerns only when Kerning for fonts covers the size. let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern }; let shaped = if use_levels { @@ -1379,24 +1441,16 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let label = env.label.as_ref().map(|(text, lvl)| { let lc = p.mark.clone().overlaid(&lvl.chr); let rc = resolve(&lc); - // A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has - // no glyph at the private-use code: draw the standard character instead, since a - // substitute has another glyph, or none, there. - let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); - let text: String = text - .chars() - .map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) { - Some(u) if r.substituted || !r.face.covers(c) => u, - _ => c, - }) - .collect(); + // A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font + // isn't installed (shaping maps it); the label's text reads as that character too. + let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) }; let before_c = b.clusters.len(); let before_g = b.glyphs.len(); - b.shape_with(&text, 0, &rc, None, false); + b.shape_with(text, 0, &rc, None, false); let mut cl: Vec = b.clusters.drain(before_c..).collect(); // A right-to-left paragraph's label reads right to left too ("1." shows as ".1"). if rp.bidi { - let lv = bidi_levels(&text, true); + let lv = bidi_levels(text, true); let cl_lv: Vec = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect(); let order = visual_order(&cl_lv); cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect(); @@ -1411,18 +1465,18 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { glyphs.push(Glyph { dx: g.dx + x, ..*g }); } - let chars = text.get(c.start..c.end).unwrap_or(""); + let chars = shown(text.get(c.start..c.end).unwrap_or("")); match runs.last_mut() { Some(r) if r.style == c.style => { r.glyphs.end = glyphs.len(); - r.text.push_str(chars); + r.text.push_str(&chars); } - _ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars.to_string() }), + _ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars }), } x += c.adv; } b.glyphs.truncate(before_g); - Label { text, style, glyphs, runs, x: 0.0, width: x } + Label { text: shown(text), style, glyphs, runs, x: 0.0, width: x } }); let mut pl = ParaLayout { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 41cd60f..8b09d0e 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -559,13 +559,42 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() { 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 label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap(); - // Where a real Symbol font with the private-use code is installed (Windows), the code stays. - let symbol = wordcraft_fonts::word::resolve("Symbol", false, false); - let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" }; - assert_eq!(label.text, expected); + // The label reads as the Unicode bullet (drawn from a real Symbol font where one is installed). + assert_eq!(label.text, "\u{2022}"); assert_eq!(label.glyphs.len(), 1); } +#[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 + // (U+F0FC = check mark, what `w:sym` writes). + let mut d = Document::from_text("Hi J\u{F0FC} ok"); + d.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".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, "Hi \u{263A}\u{2713} ok"); + // The stored text is unchanged. + assert_eq!(d.plain_text(StoryRef::Body), "Hi J\u{F0FC} ok"); + // Without Wingdings, each symbol is drawn from a face that has its Unicode character (never + // the substitute's "J" or a missing glyph). + if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted { + for want in ['\u{263A}', '\u{2713}'] { + let run = items.iter().find_map(|i| match i { + display::Draw::Glyphs { face, glyphs, text, .. } if text.contains(want) => Some((*face, glyphs.clone())), + _ => None, + }); + let Some((face, glyphs)) = run else { panic!("no run for {want}") }; + if face.covers(want) { + assert!(glyphs.iter().any(|g| g.0 == face.glyph_for(want)), "{want}"); + } else { + // No installed face has it (a bare CI machine): never the letter J's glyph. + assert!(!glyphs.iter().any(|g| g.0 != 0 && g.0 == face.glyph_for('J')), "{want}"); + } + } + } +} + #[test] fn tables_lay_out_cells() { let mut d = Document::from_text("before\nafter"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2613cb5..c5e3ad9 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-11 · **Change:** trivial (Insert › Picture takes WMF and EMF, #802; 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-11 · **Change:** 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; @@ -165,6 +165,7 @@ raise readiness estimates. No Word-derived files are fixtures. |---|---|---| | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | +| 2026-10-12 | trivial | `w:sym` and symbol-font runs keep their stored characters on save; TXT export and copy write their Unicode equivalents (#776) | | 2026-10-12 | trivial | Insert › Picture, drag-and-drop and paste take Windows Metafiles and Enhanced Metafiles (#802); new row for picture formats | | 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added | | 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 5e705c8..88305fd 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-12 · **Change:** trivial (section break over a selection keeps the text, #852; 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-12 · **Change:** 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, @@ -24,6 +24,7 @@ font can't match Word's metrics. | Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 | | 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 | +| 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 | — | | 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 | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 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` | — | @@ -92,6 +93,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | Kerning only where the document asks for it, and automatic (HTML-style) paragraph spacing, both measured in Word for Windows against real .gov documents | | 2026-10-12 | trivial | A section break inserted over a selection keeps the selected text: the first section no longer comes out empty (#852) | | 2026-10-12 | trivial | Large-table scrolling (#811): per-frame page fingerprinting fixed; measurement added | +| 2026-10-12 | trivial | Symbol-font text (Wingdings, Wingdings 2/3, Webdings, Symbol) drawn as Unicode symbols (#776) | | 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |