From 70fc00c80ffd30d75f2a0fb9ec2a69aaaa9f76d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 17:33:00 +0200 Subject: [PATCH] Symbols keep their symbol font when the text font changes (#2036) (#2037) * Symbols: a w:sym character keeps its symbol font when the text font changes (#2036) Co-Authored-By: Claude Sonnet 5.5 * Co-Authored-By: Claude Sonnet 5.5 * #2036: review round 3: docx test reads sym_font, paste drops the symbol font, astral symbols stay text Co-Authored-By: Claude Sonnet 5.5 * #2036: insert.symbol decides symbol or text per character Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- crates/doc/src/para.rs | 2 +- crates/doc/src/props.rs | 45 ++++++++++++++-- crates/doc/src/resolve.rs | 18 ++++++- crates/docx/src/read/story.rs | 5 +- crates/docx/src/write/story.rs | 7 +++ crates/docx/tests/fixtures.rs | 71 +++++++++++++++++++++++++- crates/docx/tests/roundtrip.rs | 2 + crates/engine/src/cmd/edit.rs | 6 ++- crates/engine/src/cmd/insert.rs | 46 +++++++++++++++-- crates/engine/src/cmd/mod.rs | 4 ++ crates/engine/src/cmd/para.rs | 6 ++- crates/engine/src/cmd/phonetic.rs | 23 ++++++++- crates/engine/src/cmd/text.rs | 6 +-- crates/engine/src/cmd/tools.rs | 7 +-- crates/engine/src/session.rs | 3 +- crates/engine/src/tests.rs | 85 +++++++++++++++++++++++++++++++ crates/engine/src/tests_typing.rs | 49 ++++++++++++++++++ crates/formats/src/model.rs | 2 +- docs/file-format-parity.md | 3 +- 20 files changed, 366 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a1602ed34..2dc49ec83 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-18 · **Change:** trivial (opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: 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 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -139,6 +139,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. | | 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip | | 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved | | 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) | @@ -471,6 +472,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) | | 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified | | 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index ebd909459..e8bafa6f0 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -936,7 +936,7 @@ impl Paragraph { k += 1; continue; } - let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref()); + let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.shown_font()); out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c)); } out diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 98c0a2d19..afd164a2b 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -326,6 +326,11 @@ pub struct CharProps { pub style: Option, #[serde(skip_serializing_if = "Option::is_none")] pub font: Option, + /// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol + /// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is, + /// so changing the text's font keeps the symbol. `None` = the run's font. + #[serde(skip_serializing_if = "Option::is_none")] + pub sym_font: Option, /// Points. #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, @@ -542,7 +547,7 @@ impl CharProps { /// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when /// overlaid on `base`. pub fn minus(mut self, base: &CharProps) -> CharProps { - drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures); self @@ -553,7 +558,7 @@ impl CharProps { /// Plain and complex-script properties inherit independently. Commands that intend to /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { - overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures); if let Some(p) = &patch.text_effects { @@ -567,10 +572,29 @@ impl CharProps { pub fn is_empty(&self) -> bool { *self == CharProps::default() } + /// The font a character of this run is shown in: the symbol's own font when it has one + /// ([`Self::sym_font`]), else the run's font. + pub fn shown_font(&self) -> Option<&str> { + self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref()) + } + /// The same formatting for text typed or painted next to a symbol: without the symbol's own + /// font. + pub fn without_symbol(mut self) -> CharProps { + self.sym_font = None; + self + } /// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style /// is cleared too, as Word's Clear Formatting does). pub fn cleared(&self) -> CharProps { - CharProps { link: self.link.clone(), ins: self.ins, del: self.del, fmt_change: self.fmt_change.clone(), ..Default::default() } + CharProps { + link: self.link.clone(), + ins: self.ins, + del: self.del, + fmt_change: self.fmt_change.clone(), + // A symbol stays the symbol it is. + sym_font: self.sym_font.clone(), + ..Default::default() + } } /// Just the formatting: without the link, revision marks and tracked formatting change. pub fn formatting(&self) -> CharProps { @@ -1404,4 +1428,19 @@ mod tests { let back: CharProps = serde_json::from_str(&s).unwrap(); assert_eq!(back, p); } + + #[test] + fn symbol_font_is_optional_in_json_and_survives_font_changes() { + // Older documents (no `symFont`) still load. + let old: CharProps = serde_json::from_str(r#"{"font":"Arial"}"#).unwrap(); + assert_eq!(old.sym_font, None); + let p = CharProps { font: Some("Arial".into()), sym_font: Some("Wingdings".into()), ..Default::default() }; + let s = serde_json::to_string(&p).unwrap(); + assert!(s.contains(r#""symFont":"Wingdings""#), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), p); + // A font change keeps the symbol's own font; Clear Formatting too. + let changed = p.clone().overlaid(&CharProps { font: Some("Times New Roman".into()), ..Default::default() }); + assert_eq!(changed.sym_font.as_deref(), Some("Wingdings")); + assert_eq!(p.cleared().sym_font.as_deref(), Some("Wingdings")); + } } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 1e7a4ff25..4aaa73e90 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -74,7 +74,8 @@ pub struct ResolvedChar { impl ResolvedChar { fn from(c: &CharProps) -> ResolvedChar { ResolvedChar { - font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), + // A symbol character is shown in its own font (`w:sym`), whatever the run's font. + font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0), bold: c.bold.unwrap_or(false), italic: c.italic.unwrap_or(false), @@ -354,6 +355,8 @@ impl StyleSheet { } } } + // Only a character itself has a symbol font of its own (`w:sym`); a style never gives one. + cp.sym_font = None; let mut direct = c.clone(); direct.style = None; cp.overlay(&direct); @@ -365,6 +368,19 @@ impl StyleSheet { mod tests { use super::*; + /// #2036: only a character itself (direct formatting) has a symbol font of its own; a + /// style never gives one to its text. + #[test] + fn a_style_never_gives_text_a_symbol_font() { + let mut s = StyleSheet::builtin(); + if let Some(n) = s.get_mut("Normal") { + n.chr.sym_font = Some("Wingdings".into()); + } + assert_ne!(s.resolve_char(None, &CharProps::default()).font, "Wingdings"); + let direct = CharProps { sym_font: Some("Wingdings".into()), ..Default::default() }; + assert_eq!(s.resolve_char(None, &direct).font, "Wingdings"); + } + #[test] fn heading_resolves() { let s = StyleSheet::builtin(); diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 8f902ebca..e4321b776 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -659,8 +659,9 @@ impl Reader<'_> { let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20); if let Some(ch) = ch { let mut p = props.clone(); - if let Some(f) = k.attr("w:font").filter(|f| !f.is_empty()) { - p.font = Some(f.to_string()); + // The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30). + if let Some(f) = k.attr("w:font").filter(|f| !f.trim().is_empty()) { + p.sym_font = Some(f.to_string()); } self.emit_text(sc, pb, &ch.to_string(), &p); } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 6d4744489..0629f1246 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -21,6 +21,8 @@ enum Piece<'a> { Br(Option<&'static str>), NbHyphen, SoftHyphen, + /// A symbol character in its own font (`w:sym`, ECMA-376 §17.3.3.30). + Sym(&'a str, char), /// An object and its byte offset in the paragraph. Obj(&'a InlineObject, usize), } @@ -259,6 +261,7 @@ impl Writer<'_> { let range = range.clone(); let base = range.start; let Some(text) = p.text.get(range) else { continue }; + let sym_font = props.sym_font.as_deref().filter(|f| !f.trim().is_empty()); let mut buf = String::new(); for (ci, c) in text.char_indices() { let special = match c { @@ -276,6 +279,9 @@ impl Writer<'_> { None => Piece::Text(String::new()), }) } + // `w:char` is a two-byte code (ST_ShortHexNumber) from U+0020, as the reader + // takes it; anything else stays text. + c if (0x20..=0xFFFF).contains(&u32::from(c)) => sym_font.map(|f| Piece::Sym(f, c)), _ => None, }; match special { @@ -358,6 +364,7 @@ impl Writer<'_> { Piece::Br(Some(t)) => w.empty("w:br", &[("w:type", t)]), Piece::NbHyphen => w.empty("w:noBreakHyphen", &[]), Piece::SoftHyphen => w.empty("w:softHyphen", &[]), + Piece::Sym(font, c) => w.empty("w:sym", &[("w:font", font), ("w:char", &format!("{:04X}", u32::from(*c)))]), _ => {} } } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 51dd691d3..696f754e8 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -227,7 +227,7 @@ fn hyperlink_runs_and_wrappers() { assert_eq!(p[0].props_of_char(0).style.as_deref(), Some("Hyperlink")); assert_eq!(p[0].props_of_char(4).link.as_deref(), Some("https://example.org/#frag")); let sym_off = p[0].text.find('\u{F04A}').unwrap(); - assert_eq!(p[0].props_of_char(sym_off).font.as_deref(), Some("Wingdings")); + assert_eq!(p[0].props_of_char(sym_off).sym_font.as_deref(), Some("Wingdings"), "the symbol keeps its own font"); let t = p[0].props_of_char(p[0].text.len() - 1); assert_eq!((t.bold, t.italic, t.caps, t.strike, t.color), (Some(false), Some(false), Some(false), Some(true), Some(TextColor::Auto))); assert_eq!(p[1].plain_text(), "in block sdt"); @@ -258,6 +258,73 @@ fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() { assert_eq!(q[0].shown_text(), p[0].shown_text()); } +/// The `w:sym` elements of a document's main part, as written. +fn sym_elements(bytes: &[u8]) -> Vec { + let parts = unzip(bytes); + let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap()).to_string(); + xml.match_indices("").next().unwrap().to_string() + "/>").collect() +} + +#[test] +fn a_sym_character_keeps_its_symbol_font_when_the_text_font_changes() { + // #2036: ECMA-376 §17.3.3.30 — a `w:sym` character's font is its own `w:font`, whatever the + // run's font. Setting the whole paragraph in Times New Roman keeps the check mark. + let body = r#"ab"#; + let mut d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let len = paras(&d)[0].text.len(); + d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, len), &|c| c.font = Some("Times New Roman".into())).unwrap(); + let p = paras(&d)[0]; + assert_eq!(p.plain_text(), "a\u{F0FC}b", "the stored character is unchanged"); + assert_eq!(p.props_of_char(1).font.as_deref(), Some("Times New Roman"), "the run font did change"); + assert_eq!(p.shown_text(), "a\u{2713}b"); + assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "a\u{2713}b"); + // Written back as a `w:sym` in its symbol font, so Word shows the symbol too. + let bytes = wordcraft_docx::write(&d).unwrap(); + assert_eq!(sym_elements(&bytes), vec![r#""#.to_string()]); + let again = wordcraft_docx::read(&bytes).unwrap(); + let q = paras(&again)[0]; + assert_eq!(q.plain_text(), "a\u{F0FC}b"); + assert_eq!(q.shown_text(), "a\u{2713}b"); + assert_eq!(q.props_of_char(1).font.as_deref(), Some("Times New Roman")); +} + +#[test] +fn a_sym_character_round_trips_as_w_sym() { + let body = r#"Done "#; + let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let bytes = wordcraft_docx::write(&d).unwrap(); + assert_eq!( + sym_elements(&bytes), + vec![r#""#.to_string(), r#""#.to_string()] + ); + let again = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(paras(&again)[0].shown_text(), "Done \u{2713}\u{3B1}"); + assert_eq!(sym_elements(&wordcraft_docx::write(&again).unwrap()), sym_elements(&bytes)); +} + +#[test] +fn hostile_sym_fonts_never_panic() { + // Built in the model (an XML file couldn't even hold some of these names). + let huge = "W".repeat(100_000); + for font in ["", " ", "\u{1}", huge.as_str(), "Wingdings\u{FFFF}", "<&\"'>"] { + let mut d = Document::from_text("a\u{F0FC}\u{1}\u{1F600}b"); + let len = paras(&d)[0].text.len(); + let f = font.to_string(); + d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, len), &|c| c.sym_font = Some(f.clone())).unwrap(); + let _ = d.shown_text(wordcraft_doc::StoryRef::Body); + let bytes = wordcraft_docx::write(&d).unwrap(); + let _ = wordcraft_docx::read(&bytes).unwrap(); + } +} + +#[test] +fn only_printable_characters_are_written_as_w_sym() { + // `w:sym` holds a character code from U+0020 (the reader drops lower ones), like `w:t`. + let mut d = Document::from_text("a\u{7}"); + d.format_range(&wordcraft_doc::Pos::body(0, 0), &wordcraft_doc::Pos::body(0, 2), &|c| c.sym_font = Some("Wingdings".into())).unwrap(); + assert_eq!(sym_elements(&wordcraft_docx::write(&d).unwrap()), vec![r#""#.to_string()]); +} + #[test] fn pcl6_symbol_roundtrip() { let body = r#""#; @@ -266,7 +333,7 @@ fn pcl6_symbol_roundtrip() { 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.props_of_char(0).sym_font.as_deref(), Some("Symbol (PCL6)")); assert_eq!(p.shown_text(), "≥"); } } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 2ac4be891..0bb940ee8 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -56,6 +56,8 @@ fn every_char_prop_round_trips() { let all = CharProps { style: Some("Strong".into()), font: Some("Georgia".into()), + // The symbol's own font: the run is written as `w:sym` characters (#2036). + sym_font: Some("Wingdings".into()), size: Some(10.5), bold: Some(true), italic: Some(false), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index aa8861a8b..e80d76ca2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -699,7 +699,8 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult { super::paste_text(s, &with)?; continue; } - let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default(); + // Replacing a symbol gives the new text the symbol's formatting, not its own font. + let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone().without_symbol()).unwrap_or_default(); let mut at = s.doc.delete_range(&a, &b)?; for (k, part) in with.split(['\r', '\u{2029}']).enumerate() { if k > 0 { @@ -763,7 +764,8 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { fn painter_pick(s: &mut Session) { let f = s.sel.ordered().0; - let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default(); + // A symbol's own font isn't formatting to paint onto text. + let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default(); let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default(); s.painter = Some((chr, para, false)); } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index ee657166c..bdd7c5490 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -889,10 +889,27 @@ fn symbol(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params("font: a family name".into())); } let base = s.typing_props(); - let mut props = base.clone(); - props.font = Some(font.to_string()); - s.pending = Some(props); - let r = type_text(s, c); + // A symbol font's character remembers it as its own font (`w:sym`), so changing the text's + // font later keeps the symbol; other fonts are ordinary run formatting. `w:sym` holds a 16-bit + // code, so a character beyond the Basic Multilingual Plane stays ordinary text in that font. + // Each character decides for itself, so a mixed string keeps its symbols and its text apart. + let font_is_symbol = wordcraft_doc::symbol_fonts::is_symbol_font(font); + let mut r = Ok(()); + let mut buf = [0u8; 4]; + for ch in c.chars() { + let mut props = base.clone(); + let private_use = ('\u{F020}'..='\u{F0FF}').contains(&ch); + if u32::from(ch) <= 0xFFFF && (font_is_symbol || private_use) { + props.sym_font = Some(font.to_string()); + } else { + props.font = Some(font.to_string()); + } + s.pending = Some(props); + r = type_text(s, ch.encode_utf8(&mut buf)); + if r.is_err() { + break; + } + } // Typing on after the symbol goes on in the text's own font. s.pending = Some(base); r?; @@ -1159,6 +1176,27 @@ mod tests { } /// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after. + #[test] + fn a_mixed_symbol_string_keeps_symbols_and_text_apart() { + let mut s = Session::new(wordcraft_doc::Document::from_text("a")); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("insert.symbol", &json!({"char": "\u{F041}A", "font": "My Custom Font"})).unwrap(); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props_of_char(1).sym_font.as_deref(), Some("My Custom Font"), "the private-use glyph"); + assert_eq!(p.props_of_char(4).sym_font, None, "the plain letter"); + assert_eq!(p.props_of_char(4).font.as_deref(), Some("My Custom Font")); + } + + #[test] + fn an_astral_symbol_stays_text_in_its_font() { + let mut s = Session::new(wordcraft_doc::Document::from_text("a")); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("insert.symbol", &json!({"char": "\u{1F600}", "font": "Wingdings"})).unwrap(); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props_of_char(1).sym_font, None, "w:sym can't hold it"); + assert_eq!(p.props_of_char(1).font.as_deref(), Some("Wingdings")); + } + #[test] fn symbol_takes_its_own_font() { let mut s = Session::new(wordcraft_doc::Document::from_text("ab")); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7e36ba619..5ccf5bf48 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -287,6 +287,10 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> if let Some(p) = controls::prepare_edit(s)? { props = p; } + // Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036). + if !enter { + props = props.without_symbol(); + } delete_selection(s)?; // Typed text is new: never deleted, and not a formatting change of the text around it. props.fmt_change = None; diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 2de476925..d8170dc2e 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -941,7 +941,8 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult { st.para = para.props.clone(); st.para.style = None; st.para.numbering = None; - st.chr = para.props_at(s.sel.focus.off).clone(); + // A symbol's own font is the symbol's, not the style's. + st.chr = para.props_at(s.sel.focus.off).clone().without_symbol(); st.chr.style = None; st.chr.link = None; st.chr.ins = None; @@ -996,7 +997,8 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult { if !matches!(target.kind, StyleKind::Paragraph | StyleKind::Character) { return Err(CmdError::Failed(format!("`{}` can't be updated to match the selection", target.name))); } - let direct = if s.sel.is_collapsed() { para.props_at(a.off) } else { para.props_of_char(a.off) }.clone(); + // A symbol's own font is the symbol's, not the style's. + let direct = if s.sel.is_collapsed() { para.props_at(a.off) } else { para.props_of_char(a.off) }.clone().without_symbol(); let para_style = para.props.style.clone().unwrap_or_else(|| "Normal".into()); // How the selection looks: document defaults, its paragraph style chain, its character // style chain, direct formatting (the resolve order, before defaults are filled in). diff --git a/crates/engine/src/cmd/phonetic.rs b/crates/engine/src/cmd/phonetic.rs index 4ac13fe8c..50262aa50 100644 --- a/crates/engine/src/cmd/phonetic.rs +++ b/crates/engine/src/cmd/phonetic.rs @@ -82,7 +82,11 @@ fn phonetic(s: &mut Session, v: &Value) -> CmdResult { if base.chars().count() > MAX_CHARS { return Err(CmdError::Failed(format!("a phonetic guide covers at most {MAX_CHARS} characters"))); } - let props = para.props_of_char(a.off).clone(); + // One set of formatting draws the whole guide: a symbol font only when every base character + // has it, else the symbol's font would turn the other letters and the guide into symbols. + let first = para.props_of_char(a.off).clone(); + let all_symbols = base.char_indices().all(|(i, _)| para.props_of_char(a.off + i).sym_font == first.sym_font); + let props = if all_symbols { first } else { first.without_symbol() }; let bsz = base_size(s, &a); let size = size.unwrap_or_else(|| default_size(bsz)); let raise = ruby_raise(offset.unwrap_or(0.0), bsz, size).clamp(0.0, 1584.0); @@ -159,6 +163,23 @@ mod tests { s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).cloned().unwrap() } + /// #2036: a guide over text that starts with a symbol doesn't draw the rest (and the guide) + /// in the symbol font; a guide over symbols only keeps their font. + #[test] + fn a_phonetic_guide_takes_a_symbol_font_only_over_symbols() { + for (text, kept) in [("\u{F0FC}ab", None), ("\u{F0FC}\u{F0FB}", Some("Wingdings".to_string()))] { + let mut s = session(text); + let end = wordcraft_doc::Pos::body(0, text.len()); + s.doc.format_range(&wordcraft_doc::Pos::body(0, 0), &end, &|c| c.sym_font = Some("Wingdings".into())).unwrap(); + if kept.is_none() { + s.doc.format_range(&wordcraft_doc::Pos::body(0, 3), &end, &|c| c.sym_font = None).unwrap(); + } + s.run("select.text", &json!({"text": text})).unwrap(); + s.run("format.phonetic", &json!({"ruby": "r"})).unwrap(); + assert_eq!(para(&s).props_of_char(0).sym_font, kept, "{text:?}"); + } + } + #[test] fn phonetic_guide_applies_edits_removes_and_undoes() { let mut s = session("I read 漢字 daily."); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 10bc18d48..354a6f735 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -190,7 +190,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { { // "word -- word" → "word – word" once the next word is typed. let p = s.doc.para_mut(f.story, &f.path)?; - let props = p.props_at(i + 1).clone(); + let props = p.props_at(i + 1).clone().without_symbol(); p.delete(i + 1, i + 3)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); @@ -199,7 +199,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { && !before.trim_end()[i + 3..].contains(' ') { let p = s.doc.para_mut(f.story, &f.path)?; - let props = p.props_at(i + 1).clone(); + let props = p.props_at(i + 1).clone().without_symbol(); p.delete(i + 1, i + 2)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 2, ..f }); @@ -209,7 +209,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { && before.trim_end()[..i].chars().next_back().is_some_and(|c| c != ' ') { let p = s.doc.para_mut(f.story, &f.path)?; - let props = p.props_at(i).clone(); + let props = p.props_at(i).clone().without_symbol(); p.delete(i, i + 2)?; p.insert_text(i, "\u{2014}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index ab7a28cd5..b02408dd2 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -868,7 +868,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> for (c, rep) in letters.chars().rev().zip(reps.iter().rev()) { let from = end.saturating_sub(c.len_utf8()); if rep.chars().ne(std::iter::once(c)) { - let props = para.props_of_char(from).clone(); + let props = para.props_of_char(from).clone().without_symbol(); para.delete(from, end)?; para.insert_text(from, rep, &props)?; } @@ -910,7 +910,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> if let Some(rep) = rep { let end = start + word.len(); let para = s.doc.para_mut(f.story, &f.path)?; - let props = para.props_of_char(from).clone(); + // The replacement is text: a symbol it replaces doesn't give it its symbol font (#2036). + let props = para.props_of_char(from).clone().without_symbol(); para.delete(from, end)?; para.insert_text(from, &rep, &props)?; caret = (f.off + rep.len()).saturating_sub(end - from); @@ -971,7 +972,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> let para = s.doc.para_mut(f.story, &f.path)?; if let Some(c) = word.chars().next() { let up = upper_char(c, turkic); - let props = para.props_of_char(start).clone(); + let props = para.props_of_char(start).clone().without_symbol(); para.delete(start, start + c.len_utf8())?; para.insert_text(start, &up, &props)?; let off = (caret + up.len()).saturating_sub(c.len_utf8()); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 955ab81a6..161c8976a 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1380,7 +1380,8 @@ impl Session { // As in Word, text typed at either end of a hyperlink isn't part of it; only typing // inside the link extends it. let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; - let mut out = if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }; + // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). + let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); // A footnote or endnote number is raised by its character style; text typed after it is body text. if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { out.style = None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 549f621ea..08791daa3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1075,6 +1075,91 @@ fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() { assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}"); } +/// The text drawn on the first page. +fn drawn_text(s: &mut Session) -> String { + let layout = s.layout(); + let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); + draws.iter().filter_map(|d| if let wordcraft_layout::display::Draw::Glyphs { text, .. } = d { Some(text.as_str()) } else { None }).collect() +} + +#[test] +fn an_inserted_symbol_keeps_its_font_when_the_text_font_changes() { + // #2036: Insert › Symbol in Wingdings, then the whole document set in Times New Roman: the + // symbol stays a check mark (as Word keeps a `w:sym` character's font). + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "ab"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + run(&mut s, "text.insert", json!({"text": "c"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.font", json!({"name": "Times New Roman"})); + assert_eq!(text(&s), "ab\u{F0FC}c", "the stored character is unchanged"); + assert_eq!(drawn_text(&mut s), "ab\u{2713}c"); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "ab\u{2713}c"); + // Text typed after the symbol is in the text's font, not the symbol's. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "text.insert", json!({"text": "J"})); + assert_eq!(drawn_text(&mut s), "ab\u{2713}cJ"); + // Clear Formatting keeps the symbol too. + run(&mut s, "select.all", json!({})); + run(&mut s, "format.clear", json!({})); + assert_eq!(drawn_text(&mut s), "ab\u{2713}cJ"); +} + +#[test] +fn format_painter_does_not_carry_a_symbol_font_to_text() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "ok "})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + run(&mut s, "text.insert", json!({"text": " J"})); + // Pick up the symbol's formatting and paint it on "J". + run(&mut s, "select.text", json!({"text": "\u{F0FC}"})); + run(&mut s, "edit.formatPainter", json!({})); + run(&mut s, "select.text", json!({"text": "J"})); + run(&mut s, "edit.pasteFormat", json!({})); + let shown = drawn_text(&mut s); + assert!(shown.ends_with('J'), "{shown}"); +} + +/// "ab" then a Wingdings check mark, with the caret after it. +fn text_then_symbol() -> Session { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "ab"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + s +} + +#[test] +fn a_style_created_next_to_a_symbol_does_not_take_its_font() { + let mut s = text_then_symbol(); + let r = run(&mut s, "styles.create", json!({"name": "Mine"})); + let id = r["id"].as_str().unwrap().to_string(); + assert_eq!(s.doc.styles.get(&id).unwrap().chr.sym_font, None); + assert_eq!(drawn_text(&mut s), "ab\u{2713}"); +} + +#[test] +fn updating_a_style_to_match_a_symbol_does_not_take_its_font() { + let mut s = text_then_symbol(); + run(&mut s, "select.text", json!({"text": "\u{F0FC}"})); + run(&mut s, "styles.updateToMatch", json!({})); + assert_eq!(s.doc.styles.get("Normal").unwrap().chr.sym_font, None); + assert_eq!(drawn_text(&mut s), "ab\u{2713}"); +} + +#[test] +fn replacing_a_symbol_with_text_gives_the_text_no_symbol_font() { + let mut s = text_then_symbol(); + run(&mut s, "edit.replaceAll", json!({"text": "\u{F0FC}", "with": "OK"})); + assert_eq!(text(&s), "abOK"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(2).sym_font, None); + assert_eq!(drawn_text(&mut s), "abOK"); +} + 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/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index a77d2d002..cbfa3d7f8 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -484,6 +484,55 @@ fn dashes() { assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); } +/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font. +#[test] +fn a_dash_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + typ(&mut s, "--f "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2014}').expect("em dash"); + assert_eq!(p.props_of_char(dash).sym_font, None); +} + +/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are +/// text, not the symbol's font. +#[test] +fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() { + for keys in ["-- f ", "- f "] { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"})); + typ(&mut s, keys); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text)); + assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}"); + } +} + +/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text. +#[test] +fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"})); + typ(&mut s, "c) "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text)); + assert_eq!(p.props_of_char(c).sym_font, None); +} + +/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font. +#[test] +fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"})); + typ(&mut s, "onday "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text)); + assert_eq!(p.props_of_char(m).sym_font, None); +} + #[test] fn border_line_autoformat() { let s = typed("Above\n---\nBelow"); diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 3dfb53ea9..f63bf7791 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -758,7 +758,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt { _ => None, }, size: e.size, - font: if code || in_code { None } else { e.font.clone() }, + font: if code || in_code { None } else { e.shown_font().map(str::to_string) }, background: e.highlight.and_then(|h| h.rgb()).or(e.shading), ins: None, del: None, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 13bd079e0..eb375e62f 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-18 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -230,6 +230,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | `w:sym`: the symbol's `w:font` is kept apart from the run font (ECMA-376 §17.3.3.30), so changing the text's font (or Clear Formatting) keeps Wingdings/Symbol characters showing, and they are written back as ``; Insert › Symbol in a symbol font does the same (#2036). A private-use character with no symbol font of its own in a non-symbol run is not guessed | | 2026-10-14 | trivial | RTF export writes sections: `\sectd` formatting and `\sect` per section, round-tripping through the reader (gap 19) | | 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) | | 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) |