diff --git a/ROADMAP.md b/ROADMAP.md index ad1a7d4a7..b14c21573 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-22 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously 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-23 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously 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-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously 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. @@ -136,6 +136,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). +- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). - 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -904,3 +906,5 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) | | 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) | | 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) | +| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | +| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 002354ce2..1d4cb7b0b 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -258,7 +258,9 @@ mod tests { fn an_instant_cancel_retries_then_falls_back() { let quick = Duration::from_millis(200); let slow_cancel = || async { - std::thread::sleep(Duration::from_millis(250)); + // The test polls this future by hand, with no async runtime, so a blocking sleep + // models a slow portal response. + std::thread::sleep(Duration::from_millis(250)); // NOSONAR Vec::::new() }; assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1])); diff --git a/crates/doc/src/cjk_numbers.rs b/crates/doc/src/cjk_numbers.rs index 64a1757fa..f3b6585c4 100644 --- a/crates/doc/src/cjk_numbers.rs +++ b/crates/doc/src/cjk_numbers.rs @@ -74,6 +74,8 @@ impl Counting { out.push(u); } } + // Trailing zeros of a group are not a gap: the next group starts a new place. + zero_pending = false; match gi { 0 => out.extend(self.myriads.get(1)), 1 => out.extend(self.myriads.first()), @@ -324,6 +326,17 @@ pub(crate) fn chosung(n: u32) -> String { mod tests { use super::*; + #[test] + fn chinese_no_zero_after_group_trailing_zeros() { + assert_eq!(chinese_counting_thousand(201_000), "二十万一千"); + assert_eq!(chinese_counting_thousand(120_000), "十二万"); + assert_eq!(chinese_counting_thousand(100_100), "十万零一百"); + assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千"); + assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一"); + assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万"); + assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一"); + } + #[test] fn chinese_counting_systems() { let c = |n| chinese_counting(n); diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index 569ab0a4b..072b50a2b 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -102,6 +102,9 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option { return None; } let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?; + if !v.is_finite() { + return None; + } Some(if neg { -v } else { v }) } @@ -151,6 +154,15 @@ mod tests { assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign"); } + #[test] + fn overflowing_digit_strings_are_not_numbers() { + assert_eq!(parse_number(&"1".repeat(350), None), None); + assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None); + assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None); + let big = parse_number(&"1".repeat(300), None); + assert!(big.is_some_and(f64::is_finite), "{big:?}"); + } + #[test] fn cuban_and_peruvian_numbers_use_decimal_points() { for lang in [Some("es-CU"), Some("es-PE")] { diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 3bfbdb74f..3031f7420 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -193,7 +193,8 @@ impl Table { match idx { Some((i, true)) => { if let Some(c) = row.cells.get_mut(i) { - c.props.span += 1; + // From the clamped span: a `.json` file can carry any `u32` (#3637). + c.props.span = (c.span() + 1).min(MAX_COLS) as u32; } } Some((i, false)) => { @@ -223,7 +224,7 @@ impl Table { for (i, c) in row.cells.iter_mut().enumerate() { if g < x + c.span() { if c.span() > 1 { - c.props.span -= 1; + c.props.span = (c.span() - 1) as u32; } else { rm = Some(i); } @@ -247,7 +248,7 @@ impl Table { let removed: Vec = row.cells.drain(c0 + 1..=c1).collect(); if let Some(first) = row.cells.get_mut(c0) { for c in removed { - first.props.span += c.props.span.max(1); + first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32; let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect(); first.blocks.extend(nonempty); } @@ -549,6 +550,32 @@ mod tests { t.delete_col(99); } + /// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it + /// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS. + #[test] + fn a_huge_span_from_a_file_does_not_overflow() { + let huge = || { + let mut t = Table::new(1, 2, 200.0); + t.rows[0].cells[0].props.span = u32::MAX; + t + }; + // Column 1 is inside the spanning cell, so the cell widens. + let mut t = huge(); + t.insert_col(1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.merge_right(0, 0, 1); + assert_eq!(t.rows[0].cells.len(), 1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.rows[0].cells[1].props.span = u32::MAX; + t.merge_right(0, 0, 1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.delete_col(0); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1); + } + #[test] fn merge_and_split() { let mut t = Table::new(2, 3, 300.0); diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index fca444c22..6c3b0bcef 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -9,7 +9,7 @@ //! or not (a repeated name), data is checked before it's copied, and nesting and elements are //! capped as the XML tree reader caps a part's. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use base64::Engine as _; use quick_xml::events::{BytesStart, Event}; @@ -340,14 +340,21 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec { let rest = frag.get(open..).unwrap_or_default(); let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len()); let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len()); - let tag = rest.get(..tag_end).unwrap_or_default(); + let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default()); + let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1)); + let declared: HashSet> = start + .attributes() + .with_checks(false) + .flatten() + .filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:")) + .map(|a| a.key.as_ref().to_vec()) + .collect(); let mut out = Vec::with_capacity(rest.len() + 256); out.extend_from_slice(rest.get(..name_end).unwrap_or_default()); // Innermost declarations win: walk from the data element out, skipping prefixes seen. let mut seen: Vec<&str> = Vec::new(); for (k, v) in decls.iter().rev() { - let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' '))); - if declared || seen.contains(&k.as_str()) { + if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) { continue; } seen.push(k); @@ -463,6 +470,15 @@ mod tests { assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec()); } + #[test] + fn inherited_namespaces_do_not_rescan_a_long_root_tag() { + let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::>()]; + let frag = format!("", " a=\"1\"".repeat(4_000_000)); + let out = with_scope(frag.as_bytes(), &scope); + let out = String::from_utf8(out).unwrap(); + assert_eq!(out.matches("xmlns:p").count(), 256); + } + #[test] fn nesting_is_capped_where_the_xml_tree_reader_stops() { let inline = |levels: usize| format!("{}{}", "".repeat(levels), "".repeat(levels)); diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index 4881cf7a3..457646581 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -256,6 +256,10 @@ fn vml_commands(path: &str) -> Vec<(String, Vec)> { }; while let Some(c) = chars.next() { if c.is_ascii_alphabetic() { + // Cap the commands too, so a path of bare letters can't build a million entries. + if out.len() >= MAX_VML_PARAMS { + break; + } if !has_num { push(&mut out, 0.0); } @@ -361,6 +365,13 @@ mod tests { crate::xml::parse(xml.as_bytes()).expect("parse spPr") } + #[test] + fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() { + let path = "x".repeat(MAX_VML_PATH); + let n = vml_commands(&path).len(); + assert!(n <= MAX_VML_PARAMS, "{n} commands"); + } + #[test] fn a_bezier_segment_with_many_points_is_bounded() { // One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index f04f83ee3..e0764387c 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -121,6 +121,8 @@ impl<'d> EmbedWriter<'d> { /// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`. fn alloc(&mut self, orig: &str) -> String { + let clean = valid_part_name(orig); + let orig = clean.as_str(); // Suffixes cannot escape reserved directories or a .rels extension. Such a kept // part needs a different base name, otherwise every candidate would be reserved. let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig }; @@ -684,6 +686,29 @@ fn pt(v: f32) -> String { s.trim_end_matches('0').trim_end_matches('.').to_string() } +/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a +/// dot, or encoded separators. +fn valid_part_name(name: &str) -> String { + let mut out = Vec::new(); + for seg in name.split('/') { + let mut seg = seg.replace('\\', "_"); + for enc in ["%2f", "%5c"] { + while let Some(i) = seg.to_ascii_lowercase().find(enc) { + seg.replace_range(i..i + 3, "_"); + } + } + if seg.is_empty() || seg == "." || seg == ".." { + continue; + } + // A segment can't end in a dot either (`word/a.`). + if seg.ends_with('.') { + seg = format!("{}_", seg.trim_end_matches('.')); + } + out.push(seg); + } + if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") } +} + #[cfg(test)] mod tests { use super::*; @@ -714,4 +739,16 @@ mod tests { // Unturned: no rotation or flip left behind. assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt"); } + + #[test] + fn kept_part_names_are_valid_opc_names() { + let doc = wordcraft_doc::Document::default(); + let mut w = EmbedWriter::new(&doc); + for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] { + let name = w.alloc(orig); + assert!(!name.contains('\\'), "{name}"); + assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}"); + assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}"); + } + } } diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index c3f02cc51..7e8601902 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -116,6 +116,9 @@ pub(crate) struct Writer<'d> { used_media: std::collections::BTreeSet, /// Bounds writing text boxes inside text boxes (as layout shows them). boxes: wordcraft_doc::BoxBudget, + /// Text box stories already written: a story shown by many shapes is written once, the other + /// shapes get an empty box, so the file doesn't grow as shapes × story size. + written_boxes: std::collections::HashSet, /// Charts, diagrams and OLE objects kept from a file: the parts they need. embeds: embed::EmbedWriter<'d>, /// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding @@ -175,6 +178,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result` that ends the tag at the start of `s`, skipping quoted attribute values. +fn tag_end(s: &str) -> Option { + let mut quote = None; + for (i, c) in s.char_indices() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some(_), _) => {} + (None, '"' | '\'') => quote = Some(c), + (None, '>') => return Some(i), + _ => {} + } + } + None +} + /// The root start tag's span end and the root's direct children, or `None` for anything the /// scanner doesn't expect (CDATA, DOCTYPE, a different root). fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec)> { let open = xml.find(&format!("<{root}"))?; - let open_end = open + xml.get(open..)?.find('>')? + 1; + let open_end = open + tag_end(xml.get(open..)?)? + 1; if xml.get(open_end - 2..open_end)? == "/>" { return None; } @@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec)> { i = at + tail.find("?>")? + 2; continue; } - let gt = at + tail.find('>')? + 1; + let gt = at + tag_end(tail)? + 1; let tag = xml.get(at..gt)?; if tag.starts_with(""#, GEN.as_bytes()).is_none()); } + #[test] + fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() { + for v in [r#"w:val="x>y""#, "w:val='x>y'"] { + let orig = format!( + r#""# + ); + assert!(has_unowned(orig.as_bytes()), "{v}"); + let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap(); + assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}"); + } + } + #[test] fn a_compat_block_without_a_mode_gets_the_generated_one() { let orig = r#""#; diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 4fc8d4af8..7f3ddc450 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1126,8 +1126,12 @@ impl Writer<'_> { None => w.open("wps:txbx", &[]), } w.open("w:txbxContent", &[]); - let blocks = part.blocks.clone(); - self.blocks(w, &blocks, rels, false, depth + 1); + if self.written_boxes.insert(id) { + let blocks = part.blocks.clone(); + self.blocks(w, &blocks, rels, false, depth + 1); + } else { + w.empty("w:p", &[]); + } w.close("w:txbxContent"); w.close("wps:txbx"); self.boxes.leave(); diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index dda7f614a..0040e942c 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -234,3 +234,16 @@ fn picture_in_box_keeps_its_frame() { let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0)); } + +#[test] +fn story_shared_by_many_shapes_is_written_once() { + let mut d = Document::from_text("x"); + let text = "abcdefghij".repeat(5_000); + let o = text_box(&mut d, &text, TextBody::default()); + for _ in 0..500 { + d.insert_object(&wordcraft_doc::Pos::body(0, 0), o.clone(), &Default::default()).unwrap(); + } + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.len() < 2_000_000, "document.xml is {} bytes", xml.len()); + assert_eq!(xml.matches(text.as_str()).count(), 1); +} diff --git a/crates/engine/src/cmd/chart.rs b/crates/engine/src/cmd/chart.rs index 50321fbb0..7fd4aba1c 100644 --- a/crates/engine/src/cmd/chart.rs +++ b/crates/engine/src/cmd/chart.rs @@ -145,7 +145,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { let kind = chart_type(v)?; // No type and no data: Insert › Chart's picker, as the ribbon button does. if kind.is_none() && v.get("data").is_none() { - s.ui_requests.push(json!({"open": "insertChart"})); + s.push_ui_request(json!({"open": "insertChart"})); return sel_result(s); } let kind = kind.unwrap_or_default(); diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index b646bd459..6c2cadd41 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -529,6 +529,7 @@ fn auto_tag(doc: &Document, src: &Source) -> String { } /// The author of a source for matching untagged entries: the editor when there is no author. +#[cfg(not(target_arch = "wasm32"))] fn by_author(x: &Source) -> &str { if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() } } diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 24b2788cc..6ad46d76d 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -112,9 +112,11 @@ fn symbol_param(v: &Value, k: &str) -> Option { /// A date `YYYY-MM-DD` (a full ISO date-time is fine too): (year, month, day), checked. pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> { let t = t.trim(); - let y: i64 = t.get(0..4)?.parse().ok()?; - let m: u32 = t.get(5..7)?.parse().ok()?; - let d: u32 = t.get(8..10)?.parse().ok()?; + // Digits only: `str::parse` would also accept a leading '+'. + let field = |r: std::ops::Range| t.get(r).filter(|s| s.bytes().all(|b| b.is_ascii_digit())); + let y: i64 = field(0..4)?.parse().ok()?; + let m: u32 = field(5..7)?.parse().ok()?; + let d: u32 = field(8..10)?.parse().ok()?; if t.get(4..5) != Some("-") || t.get(7..8) != Some("-") || !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m) { return None; @@ -761,6 +763,15 @@ mod tests { assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1)); } + #[test] + fn parse_date_rejects_signs_and_non_digits() { + for bad in ["2024-+1-+5", "+123-01-01", "2024-01-+5", "2024-+1-05", "-024-01-01"] { + assert_eq!(parse_date(bad), None, "{bad}"); + } + assert_eq!(parse_date("2024-01-05"), Some((2024, 1, 5))); + assert_eq!(parse_date("2024-01-05T10:00:00"), Some((2024, 1, 5))); + } + #[test] fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() { // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8c890afd0..0dfb5e149 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { t @ ("find" | "replace" | "goto") => t, other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))), }; - s.ui_requests.push(json!({"open": tab})); + s.push_ui_request(json!({"open": tab})); Ok(json!({"open": tab})) }) // Word for Mac's own key is ⌃H. @@ -603,7 +603,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult { // Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the // Find and Replace dialog is Advanced Find. s.view.nav_pane = true; - s.ui_requests.push(json!({"focus": "navSearch"})); + s.push_ui_request(json!({"focus": "navSearch"})); return Ok(json!({"count": s.find.results.len()})); } let story = s.sel.focus.story; @@ -624,7 +624,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult { /// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read. fn advanced_find(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "text").is_none() && v.get("highlight").is_none() { - s.ui_requests.push(json!({"open": "find"})); + s.push_ui_request(json!({"open": "find"})); return Ok(json!({"count": s.find.results.len()})); } read_opts(s, v); @@ -748,7 +748,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { s.sel = Selection::caret(t); s.page_hint = idx; } - s.ui_requests.push(json!({"scrollTo": "caret"})); + s.push_ui_request(json!({"scrollTo": "caret"})); return sel_result(s); } if let Some(name) = p::str(v, "bookmark") { @@ -769,7 +769,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { } return sel_result(s); } - s.ui_requests.push(json!({"open": "goto"})); + s.push_ui_request(json!({"open": "goto"})); sel_result(s) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98564e214..6494aa55d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -101,7 +101,7 @@ pub fn specs() -> Vec { // No path (the command search, say): the app asks where to save, as File › Export // does, instead of failing on the required parameter (#2313). let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "exportPdf"})); + s.push_ui_request(json!({"open": "exportPdf"})); return Ok(json!({"exported": false})); }; let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; @@ -127,13 +127,13 @@ pub fn specs() -> Vec { ) .pure(), CommandSpec::new("file.print", "Print", "File", |s, _| { - s.ui_requests.push(json!({"open": "print"})); + s.push_ui_request(json!({"open": "print"})); sel_result(s) }) .key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I") .pure(), CommandSpec::new("file.close", "Close", "File", |s, _| { - s.ui_requests.push(json!({"close": true})); + s.push_ui_request(json!({"close": true})); sel_result(s) }) .key("Mod+W / Mod+F4") @@ -227,7 +227,7 @@ fn normal_document(s: &Session) -> Document { /// Open a document, using the saved proofing default only for plain text and Markdown. fn open(s: &mut Session, v: &Value) -> CmdResult { let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "openFile"})); + s.push_ui_request(json!({"open": "openFile"})); return sel_result(s); }; let password = p::str(v, "password"); @@ -290,7 +290,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) { Some(p) => p, None => { - s.ui_requests.push(json!({"open": "saveAs"})); + s.push_ui_request(json!({"open": "saveAs"})); return Ok(json!({"saved": false})); } }; @@ -357,7 +357,7 @@ fn is_word_2003_file(_path: &std::path::Path) -> bool { fn save_as(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "path").is_none() { - s.ui_requests.push(json!({"open": "saveAs"})); + s.push_ui_request(json!({"open": "saveAs"})); return Ok(json!({"saved": false})); } save(s, v) @@ -373,7 +373,7 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions { fn export_png(s: &mut Session, v: &Value) -> CmdResult { // No path: the app asks where to save, as file.exportPdf does (#2313). let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "exportPng"})); + s.push_ui_request(json!({"open": "exportPng"})); return Ok(json!({"exported": false})); }; let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1; @@ -384,6 +384,9 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult { // the on-screen cue while editing the body, not part of the page. let mut opts = wordcraft_render::RenderOptions::default(); opts.display.dim_header = false; + if !path.to_ascii_lowercase().ends_with(".png") { + return Err(CmdError::Params("path must end in .png".into())); + } let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts); let png = img.to_png(); crate::io::write_file(path, &png).map_err(CmdError::Failed)?; diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index 0460c2274..dfb7a4816 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { } fn load_system(s: &mut Session, _v: &Value) -> CmdResult { - s.ui_requests.push(json!({"open": "loadSystemFonts"})); + s.push_ui_request(json!({"open": "loadSystemFonts"})); Ok(json!({"pending": true})) } @@ -68,7 +68,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult { } } else { // From the ribbon: the UI shows a file picker and runs this again with the file. - s.ui_requests.push(json!({"open": "addFont"})); + s.push_ui_request(json!({"open": "addFont"})); return Ok(json!({"pending": true})); }; let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index bc2d4fdef..6dbd06935 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -139,7 +139,7 @@ pub fn specs() -> Vec { CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#), CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#), CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| { - s.ui_requests.push(json!({"open": "font"})); + s.push_ui_request(json!({"open": "font"})); sel_result(s) }) .key("Mod+D") diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index a381f3bda..2493d07f7 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -221,7 +221,7 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { } else if let Some(path) = p::str(v, "path") { crate::io::read_file(path).map_err(CmdError::Failed)? } else { - s.ui_requests.push(json!({"open": "insertPicture"})); + s.push_ui_request(json!({"open": "insertPicture"})); return sel_result(s); }; place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) @@ -295,7 +295,7 @@ fn icon(s: &mut Session, v: &Value) -> CmdResult { .iter() .map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords})) .collect(); - s.ui_requests.push(json!({"open": "icons"})); + s.push_ui_request(json!({"open": "icons"})); return Ok(json!({"icons": icons})); }; let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?; @@ -441,7 +441,7 @@ fn link(s: &mut Session, v: &Value) -> CmdResult { None => match place_target(s, v)? { Some(t) => t, None => { - s.ui_requests.push(json!({"open": "link"})); + s.push_ui_request(json!({"open": "link"})); return sel_result(s); } }, diff --git a/crates/engine/src/cmd/inspector.rs b/crates/engine/src/cmd/inspector.rs index 8d304c9a3..30cabbab5 100644 --- a/crates/engine/src/cmd/inspector.rs +++ b/crates/engine/src/cmd/inspector.rs @@ -122,7 +122,13 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult { } "paragraphFormatting" => { s.doc.format_paragraphs(&a, &b, &|p| { - *p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() }; + *p = ParaProps { + style: p.style.clone(), + numbering: p.numbering, + num_change: p.num_change.take(), + fmt_change: p.fmt_change.take(), + ..Default::default() + }; })?; } "characterStyle" => return super::format::apply(s, &|c| c.style = None), @@ -136,6 +142,7 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult { lang: c.lang.clone(), no_proof: c.no_proof, rtl: c.rtl, + fmt_change: c.fmt_change.take(), ..Default::default() }; }); diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index febd55660..66498178c 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -130,7 +130,8 @@ pub fn specs() -> Vec { } if sharpen > 0.0 { img = img.unsharpen(1.0 + sharpen / 50.0, 2); - } else if sharpen < 0.0 { + } else if sharpen < 0.0 && (-sharpen / 25.0).is_normal() { + // `blur` asserts on a subnormal width. img = img.blur(-sharpen / 25.0); } img @@ -2010,6 +2011,17 @@ mod tests { assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err()); } + /// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it. + #[test] + fn a_tiny_sharpen_value_does_not_panic() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data})).unwrap(); + for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] { + s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}")); + } + } + /// Transparency is absolute from the original pixels: 80% then 0% is opaque again /// (it used to stay faded), and repeating a value is idempotent. Corrections and color /// re-set the same way: brightness 0 and saturation 100 restore the original. diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index a2d2720ba..74777db99 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -201,7 +201,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { // Marks, hidden text and proofing change what is laid out. s.relayout(); } else { - s.ui_requests.push(json!({"open": "options", "pane": pane})); + s.push_ui_request(json!({"open": "options", "pane": pane})); } let mut r = current(s); if let Some(o) = r.as_object_mut() { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 5d8055a72..ff1d113c9 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { validate_section(&mut new)?; return with_sect(s, |x| *x = new.clone()); } - s.ui_requests.push(json!({"open": "pageSetup"})); + s.push_ui_request(json!({"open": "pageSetup"})); Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) }) .params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#), diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 3ce6d739b..c87b15200 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -199,7 +199,7 @@ pub fn specs() -> Vec { r#"{"props": ParaProps, "onPage"?: {"align"?: "left|center|right|justify|distribute", "left"?: pt, "right"?: pt} (alignment and indents as seen on the page: each paragraph's own direction, after `props`, says which edge is its start)}"#, ), CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| { - s.ui_requests.push(json!({"open": "paragraph"})); + s.push_ui_request(json!({"open": "paragraph"})); sel_result(s) }) .pure(), @@ -213,7 +213,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)"))); } let list = manage_list(s, sort == "alphabetical"); - s.ui_requests.push(json!({"open": "manageStyles"})); + s.push_ui_request(json!({"open": "manageStyles"})); Ok(list) }) .params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#) diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index b4353ac0b..5327e8e02 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -106,7 +106,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { req[k] = json!(t); } } - s.ui_requests.push(req); + // Only the newest dialog request matters; older ones would pin their payloads. + s.ui_requests.retain(|r| r["open"] != "pasteSpecial"); + s.push_ui_request(req); return Ok(json!({"formats": formats_json(&ids)})); }; let fmt = match fmt.to_ascii_lowercase().as_str() { diff --git a/crates/engine/src/cmd/phonetic.rs b/crates/engine/src/cmd/phonetic.rs index 50262aa50..9c4c900a8 100644 --- a/crates/engine/src/cmd/phonetic.rs +++ b/crates/engine/src/cmd/phonetic.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { /// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`). fn phonetic(s: &mut Session, v: &Value) -> CmdResult { let Some(rv) = v.get("ruby") else { - s.ui_requests.push(json!({"open": "phonetic"})); + s.push_ui_request(json!({"open": "phonetic"})); return Ok(state(s)); }; let text: String = match rv { diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 516ddd3dc..420774959 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1683,6 +1683,21 @@ mod tests { assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty()); } + /// Clearing formatting in the Style Inspector keeps pending formatting revisions. + #[test] + fn inspector_clear_keeps_formatting_changes() { + let mut s = formatted_while_tracking(); + s.run("review.trackChanges", &json!({"value": false})).unwrap(); + s.run("styles.inspectorClear", &json!({"level": "paragraphFormatting"})).unwrap(); + assert!(body_para(&s).props.fmt_change.is_some(), "paragraph formatting change survives"); + + let mut s = formatted_while_tracking(); + s.run("review.trackChanges", &json!({"value": false})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap(); + s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).unwrap(); + assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "character formatting change survives"); + } + /// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with /// the story each sits in. fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> { diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 2de3cfb97..b35921501 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -539,7 +539,7 @@ pub fn specs() -> Vec { }) .pure(), CommandSpec::new("file.share", "Share", "File", |s, _| { - s.ui_requests.push(json!({"open": "saveAs"})); + s.push_ui_request(json!({"open": "saveAs"})); Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."})) }) .pure(), @@ -1879,7 +1879,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult { } out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null)); if !asked { - s.ui_requests.push(json!({"open": "customizeKeyboard"})); + s.push_ui_request(json!({"open": "customizeKeyboard"})); } Ok(Value::Object(out)) } @@ -1888,7 +1888,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult { /// with no query, the Search box (`ui_requests` → the UI's command search). fn search_commands(s: &mut Session, v: &Value) -> CmdResult { let Some(query) = p::str(v, "query") else { - s.ui_requests.push(json!({"open": "commands"})); + s.push_ui_request(json!({"open": "commands"})); return Ok(json!({"matches": []})); }; let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); @@ -1984,7 +1984,7 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null)); out.insert("qat".into(), json!(l.qat())); if !asked { - s.ui_requests.push(json!({"open": "customizeRibbon"})); + s.push_ui_request(json!({"open": "customizeRibbon"})); } Ok(Value::Object(out)) } diff --git a/crates/engine/src/cmd/user_templates.rs b/crates/engine/src/cmd/user_templates.rs index db944578e..9180a8fe3 100644 --- a/crates/engine/src/cmd/user_templates.rs +++ b/crates/engine/src/cmd/user_templates.rs @@ -23,6 +23,7 @@ pub const MAX_TEMPLATES: usize = 500; /// Most category folders looked into. pub const MAX_CATEGORIES: usize = 64; /// Most directory entries looked at in one folder. +#[cfg(not(target_arch = "wasm32"))] const MAX_ENTRIES: usize = 10_000; /// Longest folder path taken, in bytes. const MAX_DIR: usize = 32 * 1024; @@ -186,6 +187,7 @@ fn entries(dir: &std::path::Path) -> (Vec, Vec String { let mut label = name; while is_template_name(label) { @@ -355,6 +357,7 @@ mod tests { /// .dotx and .dotm templates show their names the same way, without the extension; two that /// would look alike in one category show their file names. #[test] + #[cfg(not(target_arch = "wasm32"))] fn labels_drop_the_extension_alike_and_tell_twins_apart() { let d = scratch("labels"); template(&d.join("Service contract.dotm"), "a", ""); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 99ac8b5ec..bc46df315 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -91,12 +91,12 @@ pub fn specs() -> Vec { // light and dark, as the manual switch did. CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") { None => { - s.ui_requests.push(json!({"toggle": "interfaceTheme"})); + s.push_ui_request(json!({"toggle": "interfaceTheme"})); Ok(json!({"value": "toggle"})) } Some(Value::String(name)) => match name.as_str() { "light" | "dark" | "system" => { - s.ui_requests.push(json!({"interfaceTheme": name})); + s.push_ui_request(json!({"interfaceTheme": name})); Ok(json!({"value": name})) } _ => Err(CmdError::Params("interface theme: light|dark|system".into())), @@ -121,12 +121,12 @@ pub fn specs() -> Vec { .params(r#"{"value"?: bool}"#) .pure(), CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| { - s.ui_requests.push(json!({"open": "newWindow"})); + s.push_ui_request(json!({"open": "newWindow"})); sel_result(s) }) .pure(), CommandSpec::new("view.split", "Split", "View › Window", |s, _| { - s.ui_requests.push(json!({"toggle": "split"})); + s.push_ui_request(json!({"toggle": "split"})); sel_result(s) }) .key("Mod+Alt+S") @@ -256,7 +256,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult { s.view.multi_page = false; } None => { - s.ui_requests.push(json!({"open": "zoom"})); + s.push_ui_request(json!({"open": "zoom"})); } } Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit})) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 2cad4f19f..f4992ce4a 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -637,12 +637,23 @@ fn doc_bib_style(doc: &Document) -> String { doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() } +/// Most pending front-end requests a session keeps. +pub const MAX_UI_REQUESTS: usize = 64; /// Maximum undo depth. const MAX_UNDO: usize = 500; /// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. pub const UNDO_LIST_MAX: usize = 100; impl Session { + /// Queue a request for the front end. A headless session never drains the queue, so only + /// the newest [`MAX_UI_REQUESTS`] are kept. + pub fn push_ui_request(&mut self, req: Value) { + if self.ui_requests.len() >= MAX_UI_REQUESTS { + self.ui_requests.remove(0); + } + self.ui_requests.push(req); + } + /// File name of the persisted custom dictionary inside the settings folder. pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 30d447f78..bbaa5fdbf 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -249,6 +249,7 @@ pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) - /// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags /// without a two- or three-letter language, such as Word's `x-none`. +#[cfg(any(unix, windows, test))] fn primary_tag(tag: &str) -> Option { let t = plain_tag(tag)?; t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) @@ -256,7 +257,7 @@ fn primary_tag(tag: &str) -> Option { /// The language to ask the speech program for, or `None` to keep its default voice: only when /// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. -#[cfg(any(not(target_os = "windows"), test))] +#[cfg(any(unix, test))] fn voice_language(lang: Option<&str>, default: &str) -> Option { lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a8044f404..ec2e6d1d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9452,3 +9452,40 @@ fn table_cell_margins_preserves_unpassed_sides() { let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); } + +#[test] +fn headless_ui_requests_stay_bounded() { + let mut s = s(); + let big = "x".repeat(1 << 20); + for _ in 0..200 { + run(&mut s, "edit.pasteSpecial", json!({"text": big})); + run(&mut s, "edit.goto", json!({})); + } + assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len()); + let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum(); + assert!(size < 4 << 20, "queued {size} bytes"); +} + +/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with +/// page images (#3696). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_png_refuses_a_path_that_is_not_png() { + let dir = scratch_dir("export-png-extension"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + for name in ["notes.txt", "config", "report.docx", "page.png.txt"] { + let target = dir.join(name); + std::fs::write(&target, b"precious user notes").unwrap(); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}"); + assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed"); + } + // A .png path still works, whatever its case. + for name in ["page.png", "PAGE.PNG"] { + let target = dir.join(name); + s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap(); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index a5d440d91..e1f0fd886 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -55,6 +55,7 @@ fn x(s: &str) -> String { '>' => o.push_str(">"), '"' => o.push_str("""), '\'' => o.push_str("'"), + '\u{FFFE}' | '\u{FFFF}' => {} c if c.is_control() && !matches!(c, '\t' | '\n') => {} _ => o.push(c), } @@ -2725,6 +2726,11 @@ fn apply_layout(a: &Attrs, sec: &mut SectionProps) { mod tests { use super::*; + #[test] + fn escaper_drops_noncharacters() { + assert_eq!(x("a\u{FFFF}b\u{FFFE}c\u{1}d\te"), "abcd\te"); + } + #[test] fn lengths() { assert_eq!(length("1in"), Some(72.0)); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9d25e5685..904b9b618 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3791,7 +3791,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + height <= pb.bottom + 0.01) { let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) - if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top { + // With the 0.01 pt of slack the other fit checks take. + if pb.y + need > pb.bottom + 0.01 && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); applied_before = pl.rp.space_before.min(0.0); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 8e56aea82..6e762d338 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -646,6 +646,11 @@ pub(crate) fn ligature_features(l: wordcraft_doc::props::Ligatures) -> [wordcraf [f(b"liga", l.standard), f(b"clig", l.contextual), f(b"hlig", l.historical), f(b"dlig", l.discretional)] } +/// A Khmer letter, sign or digit (U+1780–U+17FF, Khmer Symbols U+19E0–U+19FF). +fn is_khmer(c: char) -> bool { + matches!(u32::from(c), 0x1780..=0x17FF | 0x19E0..=0x19FF) +} + /// Whether `c`'s capital is more than one character (ß → SS), so All Caps draws more /// characters than the document holds. fn caps_expands(c: char) -> bool { @@ -1060,7 +1065,10 @@ impl<'a> Builder<'a> { // The shaper kerns by default; Word kerns only when Kerning for fonts covers the size. // Without `w14:ligatures` the shaper's default (standard and contextual) stays. let [kern_off] = no_kern; - let all = match rc.ligatures { + // Khmer shaping builds its syllables (pre-base vowels, stacked signs) in `liga` and + // `clig`, so a run's ligature setting never reaches Khmer text: Word ignores it there. + let ligatures = rc.ligatures.filter(|_| !shape_text.chars().any(is_khmer)); + let all = match ligatures { Some(l) => { let [a, b, c, d] = ligature_features(l); [kern_off, a, b, c, d] @@ -1068,7 +1076,7 @@ impl<'a> Builder<'a> { None => [kern_off; 5], }; let from = usize::from(rc.kerned()); - let to = if rc.ligatures.is_some() { all.len() } else { 1 }; + let to = if ligatures.is_some() { all.len() } else { 1 }; let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default(); let shaped = if use_levels { wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 9fe3a5106..624b05002 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -10688,3 +10688,20 @@ fn layout_cache_invalidates_on_changed_filepath() { let text_b = shown(&l_b); assert_eq!(text_b, "/path/two/a.docx", "second layout with same cache shows /path/two/a.docx"); } + +/// Keep with next takes the same 0.01 pt of slack as the other fit checks: a kept paragraph and +/// the next one's first line that fill the page to within it stay on the page. +#[test] +fn keep_with_next_fits_to_the_hundredth_of_a_point() { + use wordcraft_doc::props::LineSpacing; + let exact = + |h: f32| ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(h)), ..Default::default() }; + let mut d = Document::from_text("Filler\nKept\nNext"); + let body = d.last_section.page_h - d.last_section.margin_top - d.last_section.margin_bottom; + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props = exact(body - 24.0 + 0.005); + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props = ParaProps { keep_next: Some(true), ..exact(12.0) }; + d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props = exact(12.0); + let l = lay(&d); + assert_eq!(l.caret(&Pos::body(1, 0)).unwrap().page, 0, "the kept paragraph stays on the first page"); + assert_eq!(l.caret(&Pos::body(2, 0)).unwrap().page, 0, "and so does the next one's first line"); +} diff --git a/crates/layout/src/tests_complex.rs b/crates/layout/src/tests_complex.rs index b83a92715..613681987 100644 --- a/crates/layout/src/tests_complex.rs +++ b/crates/layout/src/tests_complex.rs @@ -266,3 +266,21 @@ fn a_missing_roman_thai_font_is_not_drawn_in_cambria() { assert_ne!(face(true).id(), cambria.id()); assert_eq!(face(true).id(), face(false).id()); } + +/// `w14:ligatures` (Word writes `historicalDiscretional` on whole Khmer documents) turns `liga` +/// and `clig` off, which breaks Khmer OS syllables: Khmer text is shaped the same either way. +#[test] +fn khmer_ignores_the_ligature_setting() { + use wordcraft_doc::props::{CharProps, Ligatures}; + let text = "\u{179F}\u{17CA}\u{17BE}\u{1794}\u{17A2}\u{1784}\u{17D2}\u{1780}\u{17C1}\u{178F} \u{1795}\u{17D2}\u{178F}\u{1793}\u{17D2}\u{1791}\u{17B6}\u{1791}\u{17C4}\u{179F}"; + let gids = |ligatures: Option| { + let chr = CharProps { font: Some("Khmer OS Siemreap".into()), size: Some(12.0), ligatures, ..Default::default() }; + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))]; + let l = lay(&d); + para_layout(&l, 0).glyphs.iter().map(|g| g.gid).collect::>() + }; + let hd = Ligatures { historical: true, discretional: true, ..Default::default() }; + assert_eq!(gids(Some(hd)), gids(None)); + assert_eq!(gids(Ligatures::from_ooxml("none")), gids(None)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a945bf07..977b9c1da 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1657,6 +1657,12 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { r } +/// The Font dialog preview's size: the Size box's points between 6 and 72, else 12. `"NaN"` and +/// `"inf"` parse as floats too, and a NaN font size trips epaint's glyph assertion (#3663). +fn preview_size(size: &str) -> f32 { + size.trim().parse::().ok().filter(|pt| pt.is_finite()).unwrap_or(12.0).clamp(6.0, 72.0) +} + /// The character properties the Font dialog's fields stand for. fn font_props(d: &Dialog) -> serde_json::Map { let Dialog::Font { @@ -1784,7 +1790,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { .. } => { // Build the sample text format for the preview pane below. - let preview_size = size.trim().parse::().unwrap_or(12.0).clamp(6.0, 72.0); + let preview_size = preview_size(size); let preview_color = wordcraft_doc::Rgb::parse(color).map(crate::theme::c32).unwrap_or_else(|| ui.visuals().text_color()); let mut fmt = egui::TextFormat { font_id: egui::FontId::new(preview_size, egui::FontFamily::Proportional), @@ -4585,6 +4591,32 @@ mod tests { } } + /// #3663: "NaN" in the Font dialog's Size box parses as a float; the preview draws at 12 pt + /// instead of a NaN size, which tripped epaint's glyph assertion and closed the app. + #[test] + fn font_dialog_preview_ignores_a_size_that_is_not_a_number() { + for (typed, pt) in [("NaN", 12.0), ("nan", 12.0), ("inf", 12.0), ("-infinity", 12.0), ("10.5", 10.5), ("200", 72.0), ("", 12.0)] { + assert_eq!(preview_size(typed), pt, "{typed:?}"); + } + let mut app = app_with("Example"); + let _ = app.run("select.all", json!({})); + app.dialog = Dialog::open("font", &mut app); + let Some(Dialog::Font { size, .. }) = &mut app.dialog else { panic!("no font dialog") }; + *size = "NaN".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + assert!(matches!(&h.state().dialog, Some(Dialog::Font { size, .. }) if size == "NaN"), "the dialog stays open"); + } + #[test] fn font_dialog_ok_keeps_what_the_user_left_alone() { // Issue #466: OK sent every field, clearing small caps, spacing and double underline diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 2545b7f23..4e50a2cef 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -721,7 +721,15 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) { pub fn lenient(input: &Value) -> T { let mut base = serde_json::to_value(T::default()).unwrap_or(Value::Null); if base.is_object() { - overlay::(&mut base, &mut Vec::new(), input, 0); + let mut extra = Vec::new(); + overlay::(&mut base, &mut Vec::new(), input, 0, &mut extra); + // Keys with no slot in the defaults (write-only legacy settings, or unknown ones) are + // kept only after the loop, so `base` stays the size of `T` while each key is checked. + for (path, k, v) in extra { + if let Some(parent) = object_at(&mut base, &path) { + parent.insert(k, v); + } + } } serde_json::from_value(base).unwrap_or_default() } @@ -735,18 +743,34 @@ fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map(root: &mut Value, path: &mut Vec, input: &Value, depth: usize) { +fn overlay( + root: &mut Value, + path: &mut Vec, + input: &Value, + depth: usize, + extra: &mut Vec<(Vec, String, Value)>, +) { let Some(map) = input.as_object() else { return }; for (k, v) in map { let Some(parent) = object_at(root, path) else { return }; let old = parent.get(k).cloned(); if depth < 4 && v.is_object() && old.as_ref().is_some_and(Value::is_object) { path.push(k.clone()); - overlay::(root, path, v, depth + 1); + overlay::(root, path, v, depth + 1, extra); path.pop(); continue; } parent.insert(k.clone(), v.clone()); + if old.is_none() { + let ok = serde_json::from_value::(root.clone()).is_ok(); + if let Some(parent) = object_at(root, path) { + parent.remove(k); + } + if ok { + extra.push((path.clone(), k.clone(), v.clone())); + } + continue; + } if serde_json::from_value::(root.clone()).is_err() && let Some(parent) = object_at(root, path) { @@ -810,6 +834,18 @@ mod tests { use crate::theme::Appearance; use crate::{Services, UiState, WordApp}; + #[test] + fn unknown_settings_keys_load_in_linear_time() { + let mut map = serde_json::Map::new(); + for i in 0..20_000 { + map.insert(format!("unknownKey{i}"), json!(i)); + } + map.insert("miniToolbar".into(), json!(false)); + let bytes = serde_json::to_vec(&serde_json::Value::Object(map)).unwrap(); + let state = UiState::from_json(&bytes).unwrap(); + assert!(!state.mini_toolbar); + } + fn app() -> WordApp { WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b2a0e6979..31df388d5 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-22 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously 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; @@ -68,7 +68,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math | | Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC | | `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | | -| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents | +| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents. A table cell's span is any number in the file; Insert Column and Merge Cells count it as at most 63 columns, as the `.docx` reader does (#3637) | ## DOCX in depth @@ -416,3 +416,4 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-18 | trivial | Word 2007 WordArt (VML `v:textpath` in the body or a header logo) shown as a text box with its text, font, colour and warp, not dropped (#2875) | | 2026-10-20 | trivial | A folder that keeps no Unix modes (a NAS share mounted by GVFS) still takes a save instead of failing with "Operation not supported" (#3003) | | 2026-10-16 | trivial | Word's Table of Contents content control (`w:docPartObj`) is kept around a TOC that isn't in the heading-and-entries form (#2427) | +| 2026-10-23 | trivial | `.json`: a table cell span past 63 columns no longer overflows when a column is inserted through the cell or the cell is merged (#3637) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 88685b2f7..ed093c566 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -287,3 +287,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-19 | trivial | Personal templates (#2852): File › New › Personal lists the user templates folder (`/Templates`, or File › Options › Advanced › File locations › User templates), subfolders as categories, searchable; a template opens as an untitled document attached to it; Save As › Save as Word Template starts there (`file.listTemplates`, `ui.saveAsTemplate`) | | 2026-10-15 | trivial | Double-clicking a page number in a later page's footer opens the footer there: the first click no longer moves the caret to the body text and scrolls the view up to it (#2103) | | 2026-10-20 | trivial | File › Home and New: each template (thumbnail and caption) and each recent document is one button named by its caption or file name (with the path when two recent file names are the same), so a screen reader announces it instead of unnamed text (#3176) | +| 2026-10-23 | trivial | Font dialog: "NaN" or "inf" in the Size box previews the text at 12 pt instead of stopping the app while drawing the preview (#3663) |