From b2787bf36ec3bc39d29b396be8e0adeb1f705cf8 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:00:13 +0100 Subject: [PATCH 01/20] M7: add a persistent language-aware spelling review dialog Keep compact context menus, but show a correction form for user spelling/editor commands. Advance without wrapping after change, ignore or dictionary actions; reject stale targets and retain programmatic command results. Add navigation/UI regressions and light/dark preview CI. cargo xtask ci was attempted on the preparation PC but cargo is absent; hosted validation is required before integration. --- .github/workflows/spelling-review.yml | 56 ++++++ ROADMAP.md | 4 +- crates/engine/src/cmd/review.rs | 35 +++- crates/ui-egui/src/canvas.rs | 32 --- crates/ui-egui/src/dialogs_review.rs | 4 + crates/ui-egui/src/dialogs_spelling.rs | 262 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 16 +- docs/gaps.md | 5 +- docs/spelling-review.md | 24 +++ docs/ui-parity.md | 9 +- 10 files changed, 397 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/spelling-review.yml create mode 100644 crates/ui-egui/src/dialogs_spelling.rs create mode 100644 docs/spelling-review.md diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml new file mode 100644 index 000000000..cd9e7981a --- /dev/null +++ b/.github/workflows/spelling-review.yml @@ -0,0 +1,56 @@ +name: Spelling review preview + +on: + pull_request: + paths: + - 'crates/ui-egui/src/dialogs_spelling.rs' + - 'crates/ui-egui/src/dialogs_review.rs' + - 'crates/engine/src/cmd/review.rs' + - '.github/workflows/spelling-review.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: spelling-review-preview-${{ github.ref }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Install build and software-rendering dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ + libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec + with: + components: rustfmt + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + - name: Formatting + id: formatting + continue-on-error: true + run: cargo fmt --all -- --check + - name: Proofing navigation regression + run: cargo test --locked -p wordcraft-engine --lib spelling_review_dialog_navigation + - name: Correction form regressions + run: cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog -- --skip preview + - name: Render light and dark correction forms + run: cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog_preview -- --ignored --nocapture + - name: Save visual review artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: spelling-review-preview + path: spelling-review-*.png + if-no-files-found: error + - name: Require formatting success + if: steps.formatting.outcome != 'success' + run: exit 1 diff --git a/ROADMAP.md b/ROADMAP.md index b892e00e8..dc9e3fe12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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. @@ -311,6 +311,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-26 | Spelling & Grammar opens a persistent correction form with suggestions and editable replacement text. Changing, ignoring or adding a word advances to the next issue in the current story; completion stays visible. Right-click keeps its compact menu. | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | @@ -643,6 +644,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 9fcaa98a4..4c168b2a0 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -66,6 +66,7 @@ pub fn specs() -> Vec { CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(), CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue) .key(super::mac_or("F7 / Alt+F7 / Mod+Alt+L", "F7 / Alt+F7")) + .params(r#"{"wrap"?: bool (default true; false stops at the end of the current story)}"#) .pure(), CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(), CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(), @@ -989,15 +990,20 @@ fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Valu } /// F7: select the next issue after the caret and return it with suggestions. -fn next_issue(s: &mut Session, _: &Value) -> CmdResult { +fn next_issue(s: &mut Session, v: &Value) -> CmdResult { let caret = s.sel.ordered().1; let story = caret.story; let paths = s.doc.para_paths(story); let start = paths.iter().position(|p| *p == caret.path).unwrap_or(0); - for k in 0..paths.len() { - let Some(path) = paths.get((start + k) % paths.len().max(1)) else { break }; + let wrap = p::bool(v, "wrap").unwrap_or(true); + // Scan forward first, including the rest of the caret's paragraph. A second pass + // optionally visits the earlier part; never select the first typo repeatedly in + // a single-paragraph story. Dialogs disable wrapping so Ignore Once can finish. + for (path, before) in paths.iter().skip(start).map(|path| (path, false)).chain( + paths.iter().take(if wrap { start.saturating_add(1) } else { 0 }).map(|path| (path, true)), + ) { for (a, b, i) in para_issues(s, story, path) { - if k == 0 && a.off < caret.off && start + k < paths.len() && paths.len() > 1 { + if *path == caret.path && (a.off < caret.off) != before { continue; } let r = issue_json(s, &a, &b, &i); @@ -2246,3 +2252,24 @@ mod tests { } } } + +#[cfg(test)] +mod spelling_navigation_tests { + use super::*; + use wordcraft_doc::Document; + + #[test] + fn spelling_review_dialog_navigation_moves_forward_and_wraps_only_when_requested() { + for text in ["The brwn fox and brwn dog.", "The brwn fox.\nThe brwn dog."] { + let mut s = Session::new(Document::from_text(text)); + let first = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(first["text"], "brwn"); + let first_selection = s.sel.clone(); + let second = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(second["text"], "brwn"); + assert_ne!(s.sel, first_selection); + assert_eq!(s.run("review.spelling", &json!({"wrap": false})).unwrap()["done"], true); + assert_eq!(s.run("review.spelling", &json!({})).unwrap()["start"], first["start"]); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9b9e47474..2fb9ba4c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6217,38 +6217,6 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); } - /// #3340: review.spelling (the Review tab's Spelling & Grammar button and the Home tab's - /// Editor button both run it) only moved the selection to the next issue — with no way to see - /// or apply a suggestion from there, just the generic selection toolbar, it looked broken. - /// Running it now also opens the same suggestions menu Shift+F10 and a Control-click already - /// open for the word at the caret. - #[test] - fn spelling_and_grammar_opens_suggestions_for_the_issue_it_selects() { - use egui_kittest::kittest::Queryable; - let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); - a.run("text.insert", json!({"text": "The quick brwn fox"})).unwrap(); - a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); - a.run("review.spelling", json!({})).unwrap(); - assert!(a.canvas.context_menu_from_keys, "queues the open the context menu already uses for Shift+F10"); - 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); - }, - a, - ); - for _ in 0..6 { - h.step(); - } - assert!(h.state().canvas.context_menu_open, "Spelling & Grammar opens the suggestions menu"); - h.get_by_label("brown").click(); - for _ in 0..3 { - h.step(); - } - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); - } - #[test] fn floating_picture_can_be_dragged_in_front_of_or_behind_text() { for wrap in ["inFrontOfText", "behindText"] { diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index b1f38ccb9..5cb3d7164 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -17,6 +17,7 @@ use crate::theme::{Tokens, semibold}; #[derive(Clone, Debug, Serialize)] #[serde(tag = "form", rename_all = "camelCase")] pub enum ReviewDialog { + Spelling(Box), Compare(CompareForm), AutoCorrect(Box), } @@ -24,6 +25,7 @@ pub enum ReviewDialog { impl ReviewDialog { pub fn name(&self) -> &'static str { match self { + ReviewDialog::Spelling(_) => "spelling", ReviewDialog::Compare(f) if f.combine => "combine", ReviewDialog::Compare(_) => "compare", ReviewDialog::AutoCorrect(_) => "autoCorrect", @@ -33,6 +35,7 @@ impl ReviewDialog { /// The window title (English; the caller translates it). pub fn title(&self) -> &'static str { match self { + ReviewDialog::Spelling(_) => "Spelling & Grammar", ReviewDialog::Compare(f) if f.combine => "Combine Documents", ReviewDialog::Compare(_) => "Compare Documents", ReviewDialog::AutoCorrect(_) => "AutoCorrect", @@ -57,6 +60,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option { /// Draws the dialog; returns true to close it. pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut ReviewDialog) -> bool { match d { + ReviewDialog::Spelling(f) => crate::dialogs_spelling::body(app, ui, f), ReviewDialog::Compare(f) => compare_ui(app, ui, f), ReviewDialog::AutoCorrect(f) => autocorrect_ui(app, ui, f), } diff --git a/crates/ui-egui/src/dialogs_spelling.rs b/crates/ui-egui/src/dialogs_spelling.rs new file mode 100644 index 000000000..9d3fa574c --- /dev/null +++ b/crates/ui-egui/src/dialogs_spelling.rs @@ -0,0 +1,262 @@ +//! A persistent proofing form over the shared review commands, independent of language data. + +use egui::Ui; +use serde::Serialize; +use serde_json::{Value, json}; +use wordcraft_engine::Selection; + +use crate::WordApp; + +#[derive(Clone, Debug, Serialize)] +pub struct SpellingForm { + issue: Value, + replacement: String, + message: String, + #[serde(skip)] + document: u64, + #[serde(skip)] + epoch: u64, + #[serde(skip)] + selection: Selection, +} + +impl SpellingForm { + pub fn new(app: &WordApp, issue: Value) -> Self { + let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into(); + Self { + issue, + replacement, + message: String::new(), + document: app.session.document_id(), + epoch: app.session.ime_body_epoch(), + selection: app.session.sel.clone(), + } + } + + fn done(&self) -> bool { + self.issue.get("done").and_then(Value::as_bool) == Some(true) + } + + // Other windows and commands can change a target while this form is open. + // Never apply a cached suggestion to a different selection or document body. + fn current(&self, app: &mut WordApp) -> bool { + self.document == app.session.document_id() + && self.epoch == app.session.ime_body_epoch() + && self.selection == app.session.sel + && app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue) + } + + fn next(&mut self, app: &mut WordApp, wrap: bool) { + match app.execute("review.spelling", json!({"wrap": wrap})) { + Ok(issue) => *self = Self::new(app, issue), + Err(e) => self.message = e, + } + } + + fn act(&mut self, app: &mut WordApp, command: Option<&str>) { + if !self.current(app) { + self.message = tl!("The document or selection changed. Check again to continue.").into(); + return; + } + if let Some(command) = command { + let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) }; + if let Err(e) = app.run(command, params) { + self.message = e; + return; + } + } + self.next(app, false); + } +} + +/// Changing or ignoring advances within the same form; Close and Escape dismiss it. +pub fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) -> bool { + ui.set_width(420.0); + if form.done() { + ui.label(tl!("The spelling and grammar check is complete.")); + } else { + let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling"); + ui.horizontal_wrapped(|ui| { + ui.label(tl!(if spelling { "Spelling" } else { "Grammar" })); + ui.label(form.issue.get("lang").and_then(Value::as_str).unwrap_or("")); + }); + ui.label(egui::RichText::new(form.issue.get("text").and_then(Value::as_str).unwrap_or("")).strong()); + ui.label(tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or(""))); + ui.add_space(8.0); + ui.label(tl!("Suggestions")); + let suggestions: Vec<&str> = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); + egui::ScrollArea::vertical().id_salt("spelling_suggestions").max_height(120.0).show(ui, |ui| { + if suggestions.is_empty() { + ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); + } + for suggestion in &suggestions { + if ui.selectable_label(form.replacement == *suggestion, *suggestion).clicked() { + form.replacement = (*suggestion).into(); + } + } + }); + let label = ui.label(tl!("Change to:")); + ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024).desired_width(400.0)).labelled_by(label.id); + let can_change = (!form.replacement.is_empty() || suggestions.contains(&"")) && crate::widgets::enabled(app, "review.applySuggestion"); + let mut action = None; + ui.horizontal_wrapped(|ui| { + if ui.add_enabled(can_change, egui::Button::new(tl!("Change"))).clicked() { + action = Some(Some("review.applySuggestion")); + } + if ui.button(tl!("Ignore Once")).clicked() { + action = Some(None); + } + if spelling { + if ui.button(tl!("Ignore All")).clicked() { + action = Some(Some("review.ignoreAll")); + } + if ui.button(tl!("Add to Dictionary")).clicked() { + action = Some(Some("review.addToDictionary")); + } + } + }); + if let Some(command) = action { + form.act(app, command); + } + } + if !form.message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, &form.message); + } + ui.add_space(8.0); + let mut close = false; + ui.horizontal(|ui| { + if ui.button(tl!("Check Again")).clicked() { + form.next(app, true); + } + close = ui.button(tl!("Close")).clicked(); + }); + close || crate::dialogs::cancel_key_pressed(ui) +} + +#[cfg(test)] +mod tests { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::{Document, Pos, StoryRef}; + use wordcraft_engine::Session; + + use super::*; + use crate::{Services, dialogs::Dialog, dialogs_review::ReviewDialog}; + + fn app(text: &str) -> WordApp { + WordApp::new(Session::new(Document::from_text(text)), Services::default()) + } + + fn frame(ui: &mut Ui, app: &mut WordApp) { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + } + + fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> { + let mut app = app(text); + app.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app); + h.run_steps(6); + h + } + + fn form(app: &WordApp) -> &SpellingForm { + match &app.dialog { + Some(Dialog::Review(d)) => match &**d { + ReviewDialog::Spelling(f) => f, + _ => panic!("spelling form expected"), + }, + _ => panic!("spelling dialog expected"), + } + } + + #[test] + fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() { + let mut h = harness("The quick brwn fox."); + assert!(!h.state().canvas.context_menu_open); + h.get_by_label("brown").click(); + h.run_steps(2); + h.get_by_label("Change").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox."); + assert!(form(h.state()).done()); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox."); + } + + #[test] + fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() { + let text = "The brwn fox and brwn dog."; + let mut h = harness(text); + let first = h.state().session.sel.clone(); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert_ne!(h.state().session.sel, first); + assert_eq!(form(h.state()).issue["text"], "brwn"); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + h.get_by_label("Check Again").click(); + h.run_steps(3); + assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary"); + } + + #[test] + fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() { + for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] { + let text = format!("The {word} fox and {word} dog."); + let mut h = harness(&text); + h.get_by_label(button).click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty()); + } + } + + #[test] + fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() { + for replace in [false, true] { + let mut a = app("The brwn fox and brwn dog."); + let issue = a.execute("review.spelling", json!({})).unwrap(); + assert!(a.dialog.is_none(), "programmatic review calls never open a window"); + let mut f = SpellingForm::new(&a, issue); + f.replacement = "brown".into(); + if replace { + a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap(); + } else { + a.session.sel = Selection::caret(Pos::body(0, 0)); + } + let before = a.session.doc.plain_text(StoryRef::Body); + f.act(&mut a, Some("review.applySuggestion")); + assert!(!f.message.is_empty()); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), before); + } + } + + #[test] + fn spelling_review_dialog_shows_completion_without_issues_and_escape_closes() { + let mut h = harness("The quick brown fox."); + assert!(form(h.state()).done()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + } + + #[test] + #[ignore = "offscreen visual review; requires a software or hardware graphics adapter"] + fn spelling_review_dialog_preview() { + let mut a = app("The quick brwn fox."); + a.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a); + for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] { + h.ctx.set_visuals(visuals); + h.run_steps(6); + h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap(); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dca58282f..d91b6c67f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -45,6 +45,7 @@ pub mod dialogs_notes; pub mod dialogs_para; pub mod dialogs_refs; pub mod dialogs_review; +pub mod dialogs_spelling; pub mod dialogs_ribbon; pub mod dialogs_smart_art; pub mod dialogs_table_style; @@ -1114,15 +1115,12 @@ impl WordApp { if matches!(id, "insert.picture" | "insert.icon") { self.select_inserted_picture(&r); } - // review.spelling only moves the selection to the next issue; without this, the ribbon's - // Spelling & Grammar and Editor buttons looked broken — just a selection with no way to - // see or apply a suggestion (#3340). Reuse the context menu's suggestions, as Shift+F10 - // already does for the word at the caret. - if id == "review.spelling" - && let Ok(v) = &r - && v.get("done").and_then(serde_json::Value::as_bool) != Some(true) - { - self.canvas.context_menu_from_keys = true; + // User proofing opens a persistent correction form; scripts keep the engine's + // structured result. Right-click and Shift+F10 retain their compact suggestions. + if matches!(id, "review.spelling" | "review.editor") && let Ok(v) = &r { + self.dialog = Some(dialogs::Dialog::Review(Box::new(dialogs_review::ReviewDialog::Spelling(Box::new( + dialogs_spelling::SpellingForm::new(self, v.clone()), + ))))); } let r = system_clipboard::explain(self, id, r); // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. diff --git a/docs/gaps.md b/docs/gaps.md index dd16596f7..7b66eeb1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -70,7 +70,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: persistent spelling/grammar correction form added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | @@ -150,6 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/spelling-review.md b/docs/spelling-review.md new file mode 100644 index 000000000..a00313c9a --- /dev/null +++ b/docs/spelling-review.md @@ -0,0 +1,24 @@ +# Spelling and grammar correction form + +> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and visual acceptance must be verified before integration. + +Review › Spelling & Grammar, Home › Editor and F7 open a persistent correction form. The issue's text, explanation and proofing language appear above the suggestions. Choose a suggestion or type replacement text in Change to, then choose Change. Each replacement uses the existing review command and is one undoable document edit. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the existing personal dictionary. Those two actions are offered only for spelling issues. + +The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text). It does not traverse all document stories or restrict itself to a selected range. Check Again starts another search with wrapping; ignored-once occurrences can be revisited. Completion stays visible until Close or Escape. The compact right-click/Shift+F10 menu is retained. + +The form consumes language-aware engine results. It works with the existing proofing languages and is independent of French contribution #3185; adding an interface does not install a dictionary or add grammar rules. Interface labels use the shared translation lookup and fall back to English when a catalog lacks a key. No release date or completeness increase is implied. + +If another command changes the document body or selection while the form is open, an old suggestion is refused. Check Again obtains a fresh target. Read-only/protected document commands retain the engine's existing checks and display refusals in the form. + +## Verification + +- Engine regression: one- and two-paragraph navigation, forward-only completion and explicit wrapping. +- UI regressions: suggestion application and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, no-issue completion and Escape. +- Offscreen preview: the ignored spelling_review_dialog_preview test writes light/dark PNGs; inspect them before integration. +- Required repository gate: cargo xtask ci. Cargo is unavailable on the preparation PC, so hosted checks are required; test definitions alone are not evidence of success. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 20a5e6247..b2858e1e9 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-25 · **Change:** trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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). @@ -31,7 +31,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients (Filter and Sort, #778; Find in list and a merged-only view, #1576), Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **63** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **64** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Spelling & Grammar, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | @@ -78,10 +78,15 @@ Find selects the Results tab, including when it prefills selected text. Editing Footnote and endnote editing keeps body and note text at normal opacity, on light and dark pages. Headers and footers still dim outside their editing context (#2545). +## Spelling and grammar correction + +Review › Spelling & Grammar, Home › Editor and F7 open the same persistent correction form. It shows the issue and its language, suggestions and an editable replacement, with Change, Ignore Once, Ignore All and Add to Dictionary. The last two apply to spelling only. Each action advances without wrapping; completion stays visible. Right-click and Shift+F10 keep the compact suggestions menu. The form is shared by all proofing languages and does not add dictionaries or grammar rules. See [spelling-review.md](spelling-review.md) for scope and verification. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | From f6f9d65b0e9fd33a5997eeafbd02fd959ba9e9f7 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:16:49 +0100 Subject: [PATCH 02/20] M7: follow the observed Word for Windows proofing pane Observe F7 in the installed Word with synthetic text, then replace the central correction form with a right-side task pane. Show sentence context, directly clickable suggestion cards and ignore/dictionary links. Use the existing pane host, palette and original icons; copy no Microsoft assets. Keep per-window state and clear targets on document switch. Adapt UI tests, pseudo-localization exemptions and light/dark previews. Record observed measurements and remaining feature differences. Local cargo xtask ci was attempted but cargo is absent; exact-head hosted checks and visual comparison remain required. --- .github/workflows/spelling-review.yml | 4 +- ROADMAP.md | 6 +- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs_review.rs | 4 - crates/ui-egui/src/dialogs_spelling.rs | 262 ------------------ crates/ui-egui/src/documents.rs | 1 + crates/ui-egui/src/i18n/audit.rs | 4 + crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/panes.rs | 12 +- crates/ui-egui/src/proofing_pane.rs | 369 +++++++++++++++++++++++++ crates/ui-egui/src/window_ui.rs | 4 + docs/gaps.md | 6 +- docs/spelling-review.md | 29 +- docs/ui-parity.md | 10 +- 14 files changed, 430 insertions(+), 296 deletions(-) delete mode 100644 crates/ui-egui/src/dialogs_spelling.rs create mode 100644 crates/ui-egui/src/proofing_pane.rs diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml index cd9e7981a..5fcea383f 100644 --- a/.github/workflows/spelling-review.yml +++ b/.github/workflows/spelling-review.yml @@ -3,8 +3,8 @@ name: Spelling review preview on: pull_request: paths: - - 'crates/ui-egui/src/dialogs_spelling.rs' - - 'crates/ui-egui/src/dialogs_review.rs' + - 'crates/ui-egui/src/proofing_pane.rs' + - 'crates/ui-egui/src/panes.rs' - 'crates/engine/src/cmd/review.rs' - '.github/workflows/spelling-review.yml' workflow_dispatch: diff --git a/ROADMAP.md b/ROADMAP.md index dc9e3fe12..95612af68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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. @@ -311,7 +311,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| -| 2026-10-26 | Spelling & Grammar opens a persistent correction form with suggestions and editable replacement text. Changing, ignoring or adding a word advances to the next issue in the current story; completion stays visible. Right-click keeps its compact menu. | +| 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | @@ -644,7 +644,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index efa28f266..b26eed8ee 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -56,6 +56,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { json!({ "ui": app.prefs(), "view": app.session.view, + "proofing": app.spelling_review, "dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "window": [r.width(), r.height()], "canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]), diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 5cb3d7164..b1f38ccb9 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -17,7 +17,6 @@ use crate::theme::{Tokens, semibold}; #[derive(Clone, Debug, Serialize)] #[serde(tag = "form", rename_all = "camelCase")] pub enum ReviewDialog { - Spelling(Box), Compare(CompareForm), AutoCorrect(Box), } @@ -25,7 +24,6 @@ pub enum ReviewDialog { impl ReviewDialog { pub fn name(&self) -> &'static str { match self { - ReviewDialog::Spelling(_) => "spelling", ReviewDialog::Compare(f) if f.combine => "combine", ReviewDialog::Compare(_) => "compare", ReviewDialog::AutoCorrect(_) => "autoCorrect", @@ -35,7 +33,6 @@ impl ReviewDialog { /// The window title (English; the caller translates it). pub fn title(&self) -> &'static str { match self { - ReviewDialog::Spelling(_) => "Spelling & Grammar", ReviewDialog::Compare(f) if f.combine => "Combine Documents", ReviewDialog::Compare(_) => "Compare Documents", ReviewDialog::AutoCorrect(_) => "AutoCorrect", @@ -60,7 +57,6 @@ pub fn open(name: &str, app: &mut WordApp) -> Option { /// Draws the dialog; returns true to close it. pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut ReviewDialog) -> bool { match d { - ReviewDialog::Spelling(f) => crate::dialogs_spelling::body(app, ui, f), ReviewDialog::Compare(f) => compare_ui(app, ui, f), ReviewDialog::AutoCorrect(f) => autocorrect_ui(app, ui, f), } diff --git a/crates/ui-egui/src/dialogs_spelling.rs b/crates/ui-egui/src/dialogs_spelling.rs deleted file mode 100644 index 9d3fa574c..000000000 --- a/crates/ui-egui/src/dialogs_spelling.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! A persistent proofing form over the shared review commands, independent of language data. - -use egui::Ui; -use serde::Serialize; -use serde_json::{Value, json}; -use wordcraft_engine::Selection; - -use crate::WordApp; - -#[derive(Clone, Debug, Serialize)] -pub struct SpellingForm { - issue: Value, - replacement: String, - message: String, - #[serde(skip)] - document: u64, - #[serde(skip)] - epoch: u64, - #[serde(skip)] - selection: Selection, -} - -impl SpellingForm { - pub fn new(app: &WordApp, issue: Value) -> Self { - let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into(); - Self { - issue, - replacement, - message: String::new(), - document: app.session.document_id(), - epoch: app.session.ime_body_epoch(), - selection: app.session.sel.clone(), - } - } - - fn done(&self) -> bool { - self.issue.get("done").and_then(Value::as_bool) == Some(true) - } - - // Other windows and commands can change a target while this form is open. - // Never apply a cached suggestion to a different selection or document body. - fn current(&self, app: &mut WordApp) -> bool { - self.document == app.session.document_id() - && self.epoch == app.session.ime_body_epoch() - && self.selection == app.session.sel - && app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue) - } - - fn next(&mut self, app: &mut WordApp, wrap: bool) { - match app.execute("review.spelling", json!({"wrap": wrap})) { - Ok(issue) => *self = Self::new(app, issue), - Err(e) => self.message = e, - } - } - - fn act(&mut self, app: &mut WordApp, command: Option<&str>) { - if !self.current(app) { - self.message = tl!("The document or selection changed. Check again to continue.").into(); - return; - } - if let Some(command) = command { - let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) }; - if let Err(e) = app.run(command, params) { - self.message = e; - return; - } - } - self.next(app, false); - } -} - -/// Changing or ignoring advances within the same form; Close and Escape dismiss it. -pub fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) -> bool { - ui.set_width(420.0); - if form.done() { - ui.label(tl!("The spelling and grammar check is complete.")); - } else { - let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling"); - ui.horizontal_wrapped(|ui| { - ui.label(tl!(if spelling { "Spelling" } else { "Grammar" })); - ui.label(form.issue.get("lang").and_then(Value::as_str).unwrap_or("")); - }); - ui.label(egui::RichText::new(form.issue.get("text").and_then(Value::as_str).unwrap_or("")).strong()); - ui.label(tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or(""))); - ui.add_space(8.0); - ui.label(tl!("Suggestions")); - let suggestions: Vec<&str> = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); - egui::ScrollArea::vertical().id_salt("spelling_suggestions").max_height(120.0).show(ui, |ui| { - if suggestions.is_empty() { - ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); - } - for suggestion in &suggestions { - if ui.selectable_label(form.replacement == *suggestion, *suggestion).clicked() { - form.replacement = (*suggestion).into(); - } - } - }); - let label = ui.label(tl!("Change to:")); - ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024).desired_width(400.0)).labelled_by(label.id); - let can_change = (!form.replacement.is_empty() || suggestions.contains(&"")) && crate::widgets::enabled(app, "review.applySuggestion"); - let mut action = None; - ui.horizontal_wrapped(|ui| { - if ui.add_enabled(can_change, egui::Button::new(tl!("Change"))).clicked() { - action = Some(Some("review.applySuggestion")); - } - if ui.button(tl!("Ignore Once")).clicked() { - action = Some(None); - } - if spelling { - if ui.button(tl!("Ignore All")).clicked() { - action = Some(Some("review.ignoreAll")); - } - if ui.button(tl!("Add to Dictionary")).clicked() { - action = Some(Some("review.addToDictionary")); - } - } - }); - if let Some(command) = action { - form.act(app, command); - } - } - if !form.message.is_empty() { - ui.colored_label(ui.visuals().error_fg_color, &form.message); - } - ui.add_space(8.0); - let mut close = false; - ui.horizontal(|ui| { - if ui.button(tl!("Check Again")).clicked() { - form.next(app, true); - } - close = ui.button(tl!("Close")).clicked(); - }); - close || crate::dialogs::cancel_key_pressed(ui) -} - -#[cfg(test)] -mod tests { - use egui_kittest::kittest::Queryable; - use wordcraft_doc::{Document, Pos, StoryRef}; - use wordcraft_engine::Session; - - use super::*; - use crate::{Services, dialogs::Dialog, dialogs_review::ReviewDialog}; - - fn app(text: &str) -> WordApp { - WordApp::new(Session::new(Document::from_text(text)), Services::default()) - } - - fn frame(ui: &mut Ui, app: &mut WordApp) { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - } - - fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> { - let mut app = app(text); - app.run("review.spelling", json!({})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app); - h.run_steps(6); - h - } - - fn form(app: &WordApp) -> &SpellingForm { - match &app.dialog { - Some(Dialog::Review(d)) => match &**d { - ReviewDialog::Spelling(f) => f, - _ => panic!("spelling form expected"), - }, - _ => panic!("spelling dialog expected"), - } - } - - #[test] - fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() { - let mut h = harness("The quick brwn fox."); - assert!(!h.state().canvas.context_menu_open); - h.get_by_label("brown").click(); - h.run_steps(2); - h.get_by_label("Change").click(); - h.run_steps(3); - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox."); - assert!(form(h.state()).done()); - h.get_by_label("Close").click(); - h.run_steps(2); - assert!(h.state().dialog.is_none()); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox."); - } - - #[test] - fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() { - let text = "The brwn fox and brwn dog."; - let mut h = harness(text); - let first = h.state().session.sel.clone(); - h.get_by_label("Ignore Once").click(); - h.run_steps(3); - assert_ne!(h.state().session.sel, first); - assert_eq!(form(h.state()).issue["text"], "brwn"); - h.get_by_label("Ignore Once").click(); - h.run_steps(3); - assert!(form(h.state()).done()); - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); - h.get_by_label("Check Again").click(); - h.run_steps(3); - assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary"); - } - - #[test] - fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() { - for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] { - let text = format!("The {word} fox and {word} dog."); - let mut h = harness(&text); - h.get_by_label(button).click(); - h.run_steps(3); - assert!(form(h.state()).done()); - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); - assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty()); - } - } - - #[test] - fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() { - for replace in [false, true] { - let mut a = app("The brwn fox and brwn dog."); - let issue = a.execute("review.spelling", json!({})).unwrap(); - assert!(a.dialog.is_none(), "programmatic review calls never open a window"); - let mut f = SpellingForm::new(&a, issue); - f.replacement = "brown".into(); - if replace { - a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap(); - } else { - a.session.sel = Selection::caret(Pos::body(0, 0)); - } - let before = a.session.doc.plain_text(StoryRef::Body); - f.act(&mut a, Some("review.applySuggestion")); - assert!(!f.message.is_empty()); - assert_eq!(a.session.doc.plain_text(StoryRef::Body), before); - } - } - - #[test] - fn spelling_review_dialog_shows_completion_without_issues_and_escape_closes() { - let mut h = harness("The quick brown fox."); - assert!(form(h.state()).done()); - h.key_press(egui::Key::Escape); - h.run_steps(2); - assert!(h.state().dialog.is_none()); - } - - #[test] - #[ignore = "offscreen visual review; requires a software or hardware graphics adapter"] - fn spelling_review_dialog_preview() { - let mut a = app("The quick brwn fox."); - a.run("review.spelling", json!({})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a); - for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] { - h.ctx.set_visuals(visuals); - h.run_steps(6); - h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap(); - } - } -} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index c843e19d2..c7e4cd123 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -49,6 +49,7 @@ impl WordApp { self.canvas.show_other_document(seen); self.controls = Default::default(); self.dialog = None; + self.spelling_review = None; self.colors = None; self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() }; self.status_msg = None; diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 4b0eb0c68..5da104c2b 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -172,6 +172,10 @@ impl<'a> Exempt<'a> { if let Some(sugg) = app.canvas.context_issue.as_ref().and_then(|i| i.get("suggestions")).and_then(Value::as_array) { names.extend(sugg.iter().filter_map(Value::as_str).map(str::to_string)); } + // The proofing pane displays document context and dictionary suggestions, not UI wording. + if let Some(form) = &app.spelling_review { + names.extend(form.content_text()); + } Exempt { body: app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), names, math: math_placeholders } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d91b6c67f..9c2fe38c1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -45,7 +45,6 @@ pub mod dialogs_notes; pub mod dialogs_para; pub mod dialogs_refs; pub mod dialogs_review; -pub mod dialogs_spelling; pub mod dialogs_ribbon; pub mod dialogs_smart_art; pub mod dialogs_table_style; @@ -74,6 +73,7 @@ pub mod paste_picture; pub mod previews; pub mod print_page; pub mod printing; +pub mod proofing_pane; pub mod read_aloud; pub mod ribbon; pub mod ribbon_fit; @@ -590,6 +590,8 @@ pub struct WordApp { pub colors: Option, /// The Restrict Editing pane (#412). pub restrict: dialogs_review::RestrictPane, + /// The language-aware spelling and grammar task pane. + pub spelling_review: Option, pub status_msg: Option<(String, f64)>, pub previews: previews::Previews, /// Media key of the picture a pending Change Picture replaces (#147). @@ -778,6 +780,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, @@ -887,6 +890,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, @@ -1115,12 +1119,10 @@ impl WordApp { if matches!(id, "insert.picture" | "insert.icon") { self.select_inserted_picture(&r); } - // User proofing opens a persistent correction form; scripts keep the engine's - // structured result. Right-click and Shift+F10 retain their compact suggestions. + // User proofing opens the persistent task pane; scripts keep structured results. + // Right-click and Shift+F10 retain their compact suggestions menu. if matches!(id, "review.spelling" | "review.editor") && let Ok(v) = &r { - self.dialog = Some(dialogs::Dialog::Review(Box::new(dialogs_review::ReviewDialog::Spelling(Box::new( - dialogs_spelling::SpellingForm::new(self, v.clone()), - ))))); + self.spelling_review = Some(proofing_pane::SpellingForm::new(self, v.clone())); } let r = system_clipboard::explain(self, id, r); // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 00c31190e..c304ae8e1 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -30,6 +30,7 @@ pub enum Pane { AltText, FormatShape, RestrictEditing, + Proofing, Comments, } @@ -45,6 +46,7 @@ impl Pane { Self::AltText => "Alt Text", Self::FormatShape => "Format Shape", Self::RestrictEditing => "Restrict Editing", + Self::Proofing => "Proofing", Self::Comments => "Comments", } } @@ -60,6 +62,7 @@ impl Pane { Self::AltText => (Edge::End, 260.0), Self::FormatShape => (Edge::End, 280.0), Self::RestrictEditing => (Edge::End, 270.0), + Self::Proofing => (Edge::End, 400.0), Self::Comments => (Edge::End, 290.0), }; PaneState::docked(edge, width) @@ -77,11 +80,12 @@ impl Pane { Self::AltText => "altText", Self::FormatShape => "formatShape", Self::RestrictEditing => "restrictEditing", + Self::Proofing => "proofing", Self::Comments => "comments", } } - const ALL: [Self; 10] = [ + const ALL: [Self; 11] = [ Self::SmartArtText, Self::Navigation, Self::Clipboard, @@ -91,6 +95,7 @@ impl Pane { Self::AltText, Self::FormatShape, Self::RestrictEditing, + Self::Proofing, Self::Comments, ]; @@ -110,7 +115,7 @@ impl Pane { Self::AltText => Some("view.altTextPane"), Self::FormatShape => Some("view.formatShapePane"), Self::Comments => Some("view.commentsPane"), - Self::RestrictEditing => None, + Self::RestrictEditing | Self::Proofing => None, } } } @@ -163,6 +168,7 @@ pub fn open(app: &WordApp) -> Vec { (Pane::AltText, v.alt_text_pane), (Pane::FormatShape, v.format_shape_pane), (Pane::RestrictEditing, app.restrict.open), + (Pane::Proofing, app.spelling_review.is_some()), (Pane::Comments, v.comments_pane), ] .into_iter() @@ -289,6 +295,7 @@ impl PaneContent for Content<'_> { egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(app, ui)); } Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), + Pane::Proofing => crate::proofing_pane::show(app, ui), Pane::Comments => comments(app, ui), } } @@ -444,6 +451,7 @@ fn close(app: &mut WordApp, pane: Pane) { Some(id) => { let _ = app.run(id, json!({"value": false})); } + None if pane == Pane::Proofing => app.spelling_review = None, None => app.restrict.open = false, } } diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs new file mode 100644 index 000000000..bfbcb9a0c --- /dev/null +++ b/crates/ui-egui/src/proofing_pane.rs @@ -0,0 +1,369 @@ +//! Language-aware proofing task pane, laid out from black-box observation of Word on Windows. + +use egui::{Align, Align2, Sense, Stroke, Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; +use wordcraft_engine::Selection; + +use crate::WordApp; +use crate::theme::{Tokens, regular, semibold}; + +#[derive(Clone, Debug, Serialize)] +pub struct SpellingForm { + issue: Value, + replacement: String, + message: String, + context_before: String, + context_after: String, + manual: bool, + #[serde(skip)] + document: u64, + #[serde(skip)] + epoch: u64, + #[serde(skip)] + selection: Selection, +} + +impl SpellingForm { + pub fn new(app: &WordApp, issue: Value) -> Self { + let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into(); + let (a, b) = app.session.sel.ordered(); + let para = app.session.doc.para_at(&a); + let before = para.and_then(|p| p.text.get(..a.off)).unwrap_or("").rsplit(['.', '!', '?']).next().unwrap_or("").trim_start(); + let mut before: Vec = before.chars().rev().take(90).collect(); + before.reverse(); + let after = para.and_then(|p| p.text.get(b.off..)).unwrap_or("").split_inclusive(['.', '!', '?']).next().unwrap_or(""); + Self { + issue, + replacement, + message: String::new(), + context_before: before.into_iter().collect(), + context_after: after.chars().take(90).collect(), + manual: false, + document: app.session.document_id(), + epoch: app.session.ime_body_epoch(), + selection: app.session.sel.clone(), + } + } + + fn done(&self) -> bool { + self.issue.get("done").and_then(Value::as_bool) == Some(true) + } + + #[cfg(test)] + pub(crate) fn content_text(&self) -> Vec { + let mut text: Vec = self.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten() + .filter_map(Value::as_str).map(str::to_string).collect(); + text.push(format!("{}{}{}", self.context_before, self.issue.get("text").and_then(Value::as_str).unwrap_or(""), self.context_after)); + text + } + + fn current(&self, app: &mut WordApp) -> bool { + self.document == app.session.document_id() + && self.epoch == app.session.ime_body_epoch() + && self.selection == app.session.sel + && app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue) + } + + fn next(&mut self, app: &mut WordApp, wrap: bool) { + match app.execute("review.spelling", json!({"wrap": wrap})) { + Ok(issue) => *self = Self::new(app, issue), + Err(e) => self.message = e, + } + } + + fn act(&mut self, app: &mut WordApp, command: Option<&str>) { + if !self.current(app) { + self.message = tl!("The document or selection changed. Check again to continue.").into(); + return; + } + if let Some(command) = command { + let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) }; + if let Err(e) = app.run(command, params) { + self.message = e; + return; + } + } + self.next(app, false); + } +} + +/// The shared task-pane host supplies the title, menu, Close button, docking and keyboard routing. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let Some(mut form) = app.spelling_review.take() else { return }; + egui::Frame::NONE.inner_margin(egui::Margin { left: 4, right: 16, top: 0, bottom: 0 }).show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| body(app, ui, &mut form)); + }); + app.spelling_review = Some(form); +} + +fn action_link(ui: &mut Ui, text: &str) -> bool { + ui.add(egui::Button::new(egui::RichText::new(tl!(text)).color(Tokens::get(ui.ctx()).text).underline()).frame(false)).clicked() +} + +fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bool) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).inner_margin(8).show(ui, |ui| { + ui.horizontal(|ui| { + let mut job = egui::text::LayoutJob::default(); + let format = egui::TextFormat { font_id: regular(12.0), color: t.text, ..Default::default() }; + job.append(&form.context_before, 0.0, format.clone()); + let marked = egui::TextFormat { underline: Stroke::new(1.0, if spelling { t.red } else { t.blue }), ..format.clone() }; + job.append(form.issue.get("text").and_then(Value::as_str).unwrap_or(""), 0.0, marked); + job.append(&form.context_after, 0.0, format); + ui.allocate_ui_with_layout(vec2((ui.available_width() - 36.0).max(20.0), 30.0), egui::Layout::top_down(Align::Min), |ui| { + ui.add(egui::Label::new(job).wrap()); + }); + let (rect, response) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Read Aloud"))); + crate::icons::paint(ui.painter(), rect.shrink(4.0), "readAloud", t.icon, t.accent); + if response.on_hover_text(tl!("Read Aloud")).clicked() { + if form.current(app) { + if let Err(e) = app.run("review.readAloud", json!({})) { + form.message = e; + } + } else { + form.message = tl!("The document or selection changed. Check again to continue.").into(); + } + } + }); + }); +} + +fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { + let t = Tokens::get(ui.ctx()); + if form.done() { + ui.label(tl!("The spelling and grammar check is complete.")); + ui.add_space(8.0); + if action_link(ui, "Check Again") { + form.next(app, true); + } + return; + } + let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling"); + ui.label(egui::RichText::new(tl!(if spelling { "Spelling" } else { "Grammar" })).font(regular(14.0))) + .on_hover_text(form.issue.get("lang").and_then(Value::as_str).unwrap_or("")); + let message = if spelling { tl!("Not in Dictionary") } else { tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or("")) }; + ui.label(egui::RichText::new(message).font(regular(11.0)).color(t.text_dim)); + ui.add_space(8.0); + context(ui, app, form, spelling); + ui.add_space(12.0); + ui.label(egui::RichText::new(tl!("Suggestions")).font(regular(11.0)).color(t.text_dim)); + let suggestions: Vec = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten() + .filter_map(Value::as_str).map(str::to_string).collect(); + let mut change = None; + if suggestions.is_empty() { + ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); + } else { + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for (index, suggestion) in suggestions.iter().enumerate() { + ui.push_id(index, |ui| { + if index > 0 { + ui.separator(); + } + ui.horizontal(|ui| { + let width = (ui.available_width() - 34.0).max(20.0); + let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, suggestion)); + if response.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover); + } + ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text( + rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, suggestion, semibold(12.5), t.text, + ); + if response.clicked() { + change = Some(suggestion.clone()); + } + ui.menu_button("⌄", |ui| { + if ui.button(tl!("Change…")).clicked() { + form.replacement.clone_from(suggestion); + form.manual = true; + ui.close(); + } + if spelling && ui.button(tl!("Add to AutoCorrect")).clicked() { + let word = form.issue.get("text").and_then(Value::as_str).unwrap_or(""); + let params = json!({"add": {"from": word, "to": suggestion}}); + if form.current(app) { + if let Err(e) = app.run("tools.autocorrect", params) { + form.message = e; + } + } else { + form.message = tl!("The document or selection changed. Check again to continue.").into(); + } + ui.close(); + } + if ui.button(tl!("AutoCorrect Options…")).clicked() { + let _ = app.run("tools.autocorrect", json!({})); + ui.close(); + } + }).response.on_hover_text(tl!("More options")); + }); + }); + } + }); + } + if let Some(replacement) = change { + form.replacement = replacement; + form.act(app, Some("review.applySuggestion")); + return; + } + ui.add_space(6.0); + if action_link(ui, "Ignore Once") { + form.act(app, None); + } else if spelling && action_link(ui, "Ignore All") { + form.act(app, Some("review.ignoreAll")); + } else if spelling && action_link(ui, "Add to Dictionary") { + form.act(app, Some("review.addToDictionary")); + } + if suggestions.is_empty() && action_link(ui, "Change…") { + form.manual = true; + } + if form.manual { + ui.add_space(8.0); + let label = ui.label(tl!("Change to:")); + ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024)).labelled_by(label.id); + if ui.button(tl!("Change")).clicked() { + form.act(app, Some("review.applySuggestion")); + } + } + if !form.message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, &form.message); + if action_link(ui, "Check Again") { + form.next(app, true); + } + } +} + +#[cfg(test)] +mod tests { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::{Document, Pos, StoryRef}; + use wordcraft_engine::Session; + + use super::*; + use crate::Services; + + fn app(text: &str) -> WordApp { + WordApp::new(Session::new(Document::from_text(text)), Services::default()) + } + + fn frame(ui: &mut Ui, app: &mut WordApp) { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + } + + fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> { + let mut app = app(text); + app.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app); + h.run_steps(6); + h + } + + fn form(app: &WordApp) -> &SpellingForm { + app.spelling_review.as_ref().expect("proofing pane expected") + } + + #[test] + fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() { + let mut h = harness("The quick brwn fox."); + assert!(!h.state().canvas.context_menu_open); + h.get_by_label("brown").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox."); + assert!(form(h.state()).done()); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().spelling_review.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox."); + } + + #[test] + fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() { + let text = "The brwn fox and brwn dog."; + let mut h = harness(text); + let first = h.state().session.sel.clone(); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert_ne!(h.state().session.sel, first); + assert_eq!(form(h.state()).issue["text"], "brwn"); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + h.get_by_label("Check Again").click(); + h.run_steps(3); + assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary"); + } + + #[test] + fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() { + for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] { + let text = format!("The {word} fox and {word} dog."); + let mut h = harness(&text); + h.get_by_label(button).click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty()); + } + } + + #[test] + fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() { + for replace in [false, true] { + let mut a = app("The brwn fox and brwn dog."); + let issue = a.execute("review.spelling", json!({})).unwrap(); + assert!(a.dialog.is_none() && a.spelling_review.is_none(), "programmatic calls never open a pane or window"); + let mut f = SpellingForm::new(&a, issue); + f.replacement = "brown".into(); + if replace { + a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap(); + } else { + a.session.sel = Selection::caret(Pos::body(0, 0)); + } + let before = a.session.doc.plain_text(StoryRef::Body); + f.act(&mut a, Some("review.applySuggestion")); + assert!(!f.message.is_empty()); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), before); + } + } + + #[test] + fn spelling_review_dialog_shows_completion_without_issues_and_close_returns_to_document() { + let mut h = harness("The quick brown fox."); + assert!(form(h.state()).done()); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().spelling_review.is_none()); + } + + #[test] + #[ignore = "offscreen visual review; requires a software or hardware graphics adapter"] + fn spelling_review_dialog_preview() { + let mut a = app("The quick brwn fox."); + a.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a); + for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] { + h.ctx.set_visuals(visuals); + h.run_steps(6); + h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap(); + } + } + + #[test] + fn spelling_review_dialog_pane_is_local_to_each_window_and_clears_on_document_switch() { + let mut a = app("The brwn fox."); + a.run("review.spelling", json!({})).unwrap(); + let mut other = crate::window_ui::WindowUi::new(a.session.document_id()); + a.swap_window(&mut other); + assert!(a.spelling_review.is_none()); + a.swap_window(&mut other); + assert_eq!(form(&a).issue["text"], "brwn"); + a.run("file.new", json!({})).unwrap(); + assert!(a.spelling_review.is_none()); + } +} diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 9d938ee24..b28ffcc0b 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -22,6 +22,7 @@ pub(crate) struct WindowUi { pub(crate) dialog: Option, pub(crate) colors: Option, pub(crate) restrict: dialogs_review::RestrictPane, + pub(crate) spelling_review: Option, pub(crate) status_msg: Option<(String, f64)>, pub(crate) previews: previews::Previews, pub(crate) change_picture_target: Option, @@ -91,6 +92,7 @@ impl WindowUi { dialog: None, colors: None, restrict: Default::default(), + spelling_review: None, status_msg: None, previews: Default::default(), change_picture_target: None, @@ -212,6 +214,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, @@ -308,6 +311,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, diff --git a/docs/gaps.md b/docs/gaps.md index 7b66eeb1c..3fe0a67e2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -70,7 +70,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: persistent spelling/grammar correction form added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | @@ -150,7 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/spelling-review.md b/docs/spelling-review.md index a00313c9a..5c5668f65 100644 --- a/docs/spelling-review.md +++ b/docs/spelling-review.md @@ -1,24 +1,33 @@ -# Spelling and grammar correction form +# Spelling and grammar task pane -> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and visual acceptance must be verified before integration. +> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and rendered comparison remain required before integration. -Review › Spelling & Grammar, Home › Editor and F7 open a persistent correction form. The issue's text, explanation and proofing language appear above the suggestions. Choose a suggestion or type replacement text in Change to, then choose Change. Each replacement uses the existing review command and is one undoable document edit. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the existing personal dictionary. Those two actions are offered only for spelling issues. +## Word reference -The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text). It does not traverse all document stories or restrict itself to a selected range. Check Again starts another search with wrapping; ignored-once occurrences can be revisited. Completion stays visible until Close or Escape. The compact right-click/Shift+F10 menu is retained. +Observed the actual Word for Windows interface on the preparation PC on 2026-10-26, using a newly created document with the synthetic sentence "Le chat mange une souri. Ce documant contient une faute." F7 selected "documant" and opened a right-side pane titled Vérification. Existing documents were left unchanged. The exact Word build was not recorded. -The form consumes language-aware engine results. It works with the existing proofing languages and is independent of French contribution #3185; adding an interface does not install a dictionary or add grammar rules. Interface labels use the shared translation lookup and fall back to English when a catalog lacks a key. No release date or completeness increase is implied. +At a 1920 × 1152 window, the pane occupied approximately 400 logical pixels on the right. Its body had a category heading and dictionary explanation, a framed sentence context approximately 48 px high, a Suggestions heading, and three bordered suggestion rows approximately 68 px high. Ignore Once, Ignore All and Add to Dictionary were text actions below the list. A suggestion's chevron opened Read Aloud, Spell Out, Change All and AutoCorrect actions. These are observations, not a copied screenshot or asset. -If another command changes the document body or selection while the form is open, an old suggestion is refused. Check Again obtains a fresh target. Read-only/protected document commands retain the engine's existing checks and display refusals in the form. +WordCraft uses this arrangement and its shared pane host, icons, typography and theme palette. Clicking a suggestion applies the correction directly and advances; the central modal proposal is superseded. The pane supports sentence context, Read Aloud through the existing command, manual replacement and AutoCorrect actions. Word's dictionary definitions/synonyms, Spell Out and Change All are not implemented here. Visual similarity must be assessed from the generated light/dark previews; implementation alone is not evidence of pixel identity. + +## Scope and behavior + +Review › Spelling & Grammar, Home › Editor and F7 open the task pane. The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text), not all document stories or only a selection. Each correction uses the existing review command and remains undoable. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the personal dictionary. Those two actions are offered only for spelling issues. Completion remains visible; Check Again starts a wrapping search. The pane host supplies Close and docking. + +If another command changes the document body or selection, a cached suggestion is refused; Check Again obtains a fresh target. Read-only/protected document commands retain their checks. The pane is local to each window, clears on a document switch and does not alter the compact right-click menu. Programmatic review commands return structured results without opening UI. + +This work is independent of French contribution #3185. It consumes language-aware engine results and adds neither dictionaries nor grammar rules. Labels use the external shared translation lookup with English fallback for missing keys. No release date or readiness increase is implied. ## Verification -- Engine regression: one- and two-paragraph navigation, forward-only completion and explicit wrapping. -- UI regressions: suggestion application and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, no-issue completion and Escape. -- Offscreen preview: the ignored spelling_review_dialog_preview test writes light/dark PNGs; inspect them before integration. -- Required repository gate: cargo xtask ci. Cargo is unavailable on the preparation PC, so hosted checks are required; test definitions alone are not evidence of success. +- Engine: one-/two-paragraph navigation, forward-only completion and explicit wrapping. +- UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching. +- Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. +- Required gate: cargo xtask ci. Cargo is unavailable on the preparation PC; hosted validation is required, and test definitions alone do not establish success. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Actual Word for Windows observation replaces the modal proposal with a right-side Proofing pane, sentence context and directly clickable suggestion cards | | 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b2858e1e9..7c7a29fa8 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-26 · **Change:** minor (persistent spelling and grammar correction dialog); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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). @@ -31,7 +31,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients (Filter and Sort, #778; Find in list and a merged-only view, #1576), Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **64** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Spelling & Grammar, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **63** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | @@ -80,13 +80,15 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh ## Spelling and grammar correction -Review › Spelling & Grammar, Home › Editor and F7 open the same persistent correction form. It shows the issue and its language, suggestions and an editable replacement, with Change, Ignore Once, Ignore All and Add to Dictionary. The last two apply to spelling only. Each action advances without wrapping; completion stays visible. Right-click and Shift+F10 keep the compact suggestions menu. The form is shared by all proofing languages and does not add dictionaries or grammar rules. See [spelling-review.md](spelling-review.md) for scope and verification. +Review › Spelling & Grammar, Home › Editor and F7 open a right-side Proofing task pane. Black-box observation of Word for Windows on 2026-10-26 established the arrangement: issue category, dictionary explanation, sentence context with the issue underlined, a bordered stack of suggestion cards, then Ignore Once, Ignore All and Add to Dictionary links. Clicking a suggestion applies it and advances. The pane uses WordCraft's existing title/docking/close host and its own icons and palette; no Microsoft assets are copied. The suggestion menu exposes supported manual replacement and AutoCorrect actions; Word's synonyms, spelling-out and Change All are not implemented here. No pixel-identical claim is made until the rendered comparison is inspected. + +Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared by all proofing languages; it adds no dictionary or grammar rule. See [spelling-review.md](spelling-review.md) for scope, observation and verification. ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Persistent Spelling & Grammar form uses shared language-aware review commands; Change, Ignore Once/All and Add to Dictionary advance without wrapping; stale targets are refused; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | From 9e4adaf3202e5ba0ffb095f07dbfcbcc00a443cb Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:21:10 +0100 Subject: [PATCH 03/20] M7: apply the hosted rustfmt corrections to the proofing pane Apply the six formatting hunks reported by the exact-head Printing/CI runs. No behavior change. cargo xtask ci was attempted locally but cargo is absent; hosted checks must validate this revision. --- crates/engine/src/cmd/review.rs | 9 ++++++--- crates/ui-egui/src/lib.rs | 4 +++- crates/ui-egui/src/proofing_pane.rs | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 4c168b2a0..873c7f674 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -999,9 +999,12 @@ fn next_issue(s: &mut Session, v: &Value) -> CmdResult { // Scan forward first, including the rest of the caret's paragraph. A second pass // optionally visits the earlier part; never select the first typo repeatedly in // a single-paragraph story. Dialogs disable wrapping so Ignore Once can finish. - for (path, before) in paths.iter().skip(start).map(|path| (path, false)).chain( - paths.iter().take(if wrap { start.saturating_add(1) } else { 0 }).map(|path| (path, true)), - ) { + for (path, before) in paths + .iter() + .skip(start) + .map(|path| (path, false)) + .chain(paths.iter().take(if wrap { start.saturating_add(1) } else { 0 }).map(|path| (path, true))) + { for (a, b, i) in para_issues(s, story, path) { if *path == caret.path && (a.off < caret.off) != before { continue; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c2fe38c1..9b0eb7577 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1121,7 +1121,9 @@ impl WordApp { } // User proofing opens the persistent task pane; scripts keep structured results. // Right-click and Shift+F10 retain their compact suggestions menu. - if matches!(id, "review.spelling" | "review.editor") && let Ok(v) = &r { + if matches!(id, "review.spelling" | "review.editor") + && let Ok(v) = &r + { self.spelling_review = Some(proofing_pane::SpellingForm::new(self, v.clone())); } let r = system_clipboard::explain(self, id, r); diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index bfbcb9a0c..6710530be 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -52,8 +52,8 @@ impl SpellingForm { #[cfg(test)] pub(crate) fn content_text(&self) -> Vec { - let mut text: Vec = self.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten() - .filter_map(Value::as_str).map(str::to_string).collect(); + let mut text: Vec = + self.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); text.push(format!("{}{}{}", self.context_before, self.issue.get("text").and_then(Value::as_str).unwrap_or(""), self.context_after)); text } @@ -149,8 +149,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { context(ui, app, form, spelling); ui.add_space(12.0); ui.label(egui::RichText::new(tl!("Suggestions")).font(regular(11.0)).color(t.text_dim)); - let suggestions: Vec = form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten() - .filter_map(Value::as_str).map(str::to_string).collect(); + let suggestions: Vec = + form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); let mut change = None; if suggestions.is_empty() { ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); @@ -170,7 +170,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { ui.painter().rect_filled(rect, 0.0, t.hover); } ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text( - rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, suggestion, semibold(12.5), t.text, + rect.left_center() + vec2(12.0, 0.0), + Align2::LEFT_CENTER, + suggestion, + semibold(12.5), + t.text, ); if response.clicked() { change = Some(suggestion.clone()); @@ -197,7 +201,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { let _ = app.run("tools.autocorrect", json!({})); ui.close(); } - }).response.on_hover_text(tl!("More options")); + }) + .response + .on_hover_text(tl!("More options")); }); }); } From 574cef34c96b24f4085e5e49a7fd7eaeb452dcfb Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:27:57 +0100 Subject: [PATCH 04/20] M7: use current egui accessibility roles for proofing controls Replace two deprecated WidgetType aliases with Role::Button, as required by the hosted Windows and macOS Clippy checks. Local cargo xtask ci was attempted but cargo is not installed on this machine. git diff --check passes. Hosted CI and the preview workflow must validate this revision. --- crates/ui-egui/src/proofing_pane.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index 6710530be..2e82349ff 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -115,7 +115,7 @@ fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bo ui.add(egui::Label::new(job).wrap()); }); let (rect, response) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Read Aloud"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Read Aloud"))); crate::icons::paint(ui.painter(), rect.shrink(4.0), "readAloud", t.icon, t.accent); if response.on_hover_text(tl!("Read Aloud")).clicked() { if form.current(app) { @@ -165,7 +165,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { ui.horizontal(|ui| { let width = (ui.available_width() - 34.0).max(20.0); let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, suggestion)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, suggestion)); if response.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover); } From 20205a50511e2f3a1c59d0a9c3acce33ec219b81 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:11:10 +0100 Subject: [PATCH 05/20] M7: repair baseline test imports and publish proofing previews The full gate found missing Page/Rgb imports in main's tests_linear.rs (also tracked by upstream #4401). Add the imports already validated with the French contribution. No layout behavior changes. The preview run rendered both images and passed all six correction tests, but upload searched the workspace root. Cargo tests run in the crate directory; collect the PNGs from crates/ui-egui instead. At parent 574cef, Printing passed on Linux, macOS and Windows, including UI tests and browser compatibility. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. This revision needs new hosted validation. --- .github/workflows/spelling-review.yml | 2 +- crates/layout/src/tests_linear.rs | 988 +++++++++++++------------- 2 files changed, 495 insertions(+), 495 deletions(-) diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml index 5fcea383f..a3ead7ce3 100644 --- a/.github/workflows/spelling-review.yml +++ b/.github/workflows/spelling-review.yml @@ -49,7 +49,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: spelling-review-preview - path: spelling-review-*.png + path: crates/ui-egui/spelling-review-*.png if-no-files-found: error - name: Require formatting success if: steps.formatting.outcome != 'success' diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 31b451d8d..92c87c98d 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -1,494 +1,494 @@ -//! Lookups that once rescanned the page, the paragraph or the document for every item they -//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. -//! -//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy -//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, -//! whose walk is the document model's, is timed. - -use super::tests::{SCALE, cost_ratio}; -use super::{ - Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, ParaLayout, Paragraph, Path, Placed, - SectionProps, SectionStart, StoryRef, display, layout, para, work, -}; -use wordcraft_doc::para::Run; -use wordcraft_doc::{Pos, Table}; - -fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { - layout(d, &mut LayoutCache::new(), opts) -} - -/// The most steps 8x the input may take, against the steps of the input: linear walks take -/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x. -const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64; - -/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on -/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted. -pub(crate) fn assert_linear_steps(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) { - let (small, big) = (make(n), make(SCALE * n)); - let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big))); - let ratio = b as f64 / s.max(1) as f64; - eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}"); - assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)"); - assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear"); -} - -/// [`assert_linear_steps`] for laying out `doc(n)`. -fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) { - assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts)))); -} - -/// The first laid-out body paragraph on page 0. -fn first_para(l: &DocLayout) -> Option<&ParaLayout> { - l.pages.first()?.items.iter().find_map(|it| match it { - Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()), - _ => None, - }) -} - -/// Finding 29: proofing text found each character's run from the paragraph's first run. -#[test] -fn proof_text_walks_the_runs_once() { - // One run per character, every other one deleted. - let para = |n: usize| { - let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default()); - p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect(); - p - }; - assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); - // The runs are the document model's, whose walk counts no steps, so this one is timed: the - // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for - // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over - // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. - let (small, big) = (para(50_000), para(SCALE * 50_000)); - let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); - let mut ratios = Vec::new(); - for _ in 0..3 { - let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); - eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - if ratio < 24.0 { - return; - } - ratios.push(ratio); - } - panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); -} - -/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. -fn bidi_hidden(n: usize) -> Document { - let head = "سلام "; - let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n))); - let at = Pos::body(0, 0); - d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); - d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap(); - d -} - -/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret -/// offset from the line's first cluster. -#[test] -fn bidi_clicks_and_caret_steps_take_linear_time() { - let opts = LayoutOptions { show_hidden: false, ..Default::default() }; - let laid = |n: usize| lay(&bidi_hidden(n), &opts); - let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") }; - assert_eq!(pb, 1, "the hidden letters stay on one line"); - assert_linear_steps("bidi hit test", 5000, laid, |l| { - let Some(p) = first_para(l) else { panic!("no paragraph") }; - std::hint::black_box(p.off_at_x(0, 1.0)); - std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); - }); -} - -/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional -/// and left-to-right lines alike. -#[test] -fn line_carets_match_x_of() { - let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), { - let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end"); - let at = Pos::body(0, 0); - d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); - d - }]; - for d in &docs { - for show_hidden in [false, true] { - let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() }); - let Some(p) = first_para(&l) else { panic!("no paragraph") }; - for li in 0..p.lines.len() { - let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect(); - assert!(!want.is_empty()); - assert_eq!(p.line_carets(li), want, "line {li}"); - } - } - } -} - -/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. -#[test] -fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { - // Rows with no cells, only a column skipped before and after them with a width: two - // spanning entries a row for next to no layout, so counting the grid per entry dominates. - let doc = |n: usize| { - let mut t = Table::new(n, 1, 468.0); - for row in t.rows.iter_mut().skip(1) { - row.cells.clear(); - row.props.grid_before = 1; - row.props.w_before = Some(10.0); - row.props.grid_after = 1; - row.props.w_after = Some(10.0); - } - let mut d = Document::from_text("x"); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); - d - }; - assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default()); -} - -/// `n` continuous sections of an empty paragraph each, each with a different first page. The -/// first section names every header and footer; the others name them too, or (`inherit`) name -/// none and link to the previous section's. -fn sections(n: usize, inherit: bool) -> Document { - use wordcraft_doc::PartKind::{Footer, Header}; - let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1))); - let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); - let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)]; - let named = SectionProps { - start: SectionStart::Continuous, - title_page: true, - headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) }, - footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) }, - ..Default::default() - }; - let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() }; - for i in 0..n.saturating_sub(1) { - let s = if i == 0 || !inherit { named.clone() } else { linked.clone() }; - d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); - } - d.last_section = if inherit { linked } else { named }; - d -} - -/// Finding 13: each section's headers and footers were found by walking back through the -/// sections before it, for every section and again for every page. Linked to the previous -/// section's, they must cost no more to lay out than named in each section. -#[test] -fn headers_inherited_through_many_sections_lay_out_in_linear_time() { - let l = lay(§ions(200, true), &LayoutOptions::default()); - assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); - let opts = LayoutOptions::default(); - assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts); - let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts)))); - let (named, inherited) = (steps(false), steps(true)); - assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}"); -} - -/// A header of `n` short paragraphs. -fn tall_header(n: usize) -> (Document, u32) { - let mut d = Document::from_text("Body"); - let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Header, paras); - d.last_section.headers.default = Some(id); - (d, id) -} - -/// Finding 28: selecting across a header looked through the whole page for each paragraph. -#[test] -fn selecting_a_long_header_takes_linear_time() { - // The header laid out, and the selection from its first paragraph to its last. - let laid = |n: usize| { - let (d, id) = tall_header(n); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); - (d, l, ends) - }; - let (d, l, (a, b)) = laid(SCALE * 300); - assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph"); - assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); -} - -/// An endnote of `n` paragraphs, a few lines each, continued over many pages. -fn long_endnote(n: usize) -> (Document, u32) { - let mut d = Document::from_text("Body"); - // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as - // the paragraphs and each paragraph costs next to nothing. - let huge = CharProps { size: Some(400.0), ..Default::default() }; - let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); - let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; - d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); - (d, id) -} - -/// #3866: selecting across an endnote over many pages probed the pages from the first for each -/// paragraph (paragraphs x pages). -#[test] -fn selecting_a_long_endnote_takes_linear_time() { - let laid = |n: usize| { - let (d, id) = long_endnote(n); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); - (d, l, ends) - }; - let (d, l, (a, b)) = laid(SCALE * 20); - assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); - assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); - assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); -} - -/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its -/// (empty) paragraph and the whole table fits on the first page. -fn clipped_table(n: usize) -> Document { - let mut t = Table::new(n, 60, 468.0); - for row in &mut t.rows { - row.props.height = Some(0.5); - row.props.height_rule = HeightRule::Exact; - } - let mut d = Document::from_text("x"); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); - d -} - -/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. -#[test] -fn drawing_many_clipped_cells_takes_linear_time() { - let laid = |n: usize| { - let d = clipped_table(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - let opts = display::DisplayOptions::default(); - let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); - let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count(); - assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); - assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl)))); -} - -/// `n` words, each inserted by its own author, at a size that fits them all on the first page. -fn tracked_words(n: usize) -> Document { - let mut d = Document::from_text("x"); - let p = Paragraph { - text: "word ".repeat(n), - runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(), - ..Paragraph::with_text("", CharProps::default()) - }; - d.body = vec![wordcraft_doc::para_block(p)]; - d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); - d -} - -/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. -#[test] -fn drawing_tracked_changes_by_many_authors_takes_linear_time() { - let laid = |n: usize| { - let d = tracked_words(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - assert_eq!(laid(SCALE * 1000).1.pages.len(), 1); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); - // Every run is drawn in its author's colour, so the authors are ranked at least once. - assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl)))); -} - -/// Each author keeps the colour of their first revision's place among the authors. -#[test] -fn tracked_changes_colour_by_author_in_order_of_appearance() { - let mut d = tracked_words(3); - // Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second. - for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) { - r.author = a.into(); - } - let l = lay(&d, &LayoutOptions::default()); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts) - .into_iter() - .filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None }) - .collect(); - let (ana, ben) = (display::revision_color(0), display::revision_color(1)); - assert_ne!(ana, ben); - assert_eq!(colours, [ana, ben, ana]); -} - -/// Finding 30 (#3706): every comment balloon ranked its author among all the comments. -#[test] -fn balloons_for_many_authors_lay_out_in_linear_time() { - let doc = |n: usize| { - let mut d = Document::from_text(&"word ".repeat(n)); - for i in (0..n).rev() { - let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); - let id = u32::try_from(i).unwrap(); - d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() }); - let p = CharProps::default(); - d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap(); - d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap(); - } - d - }; - let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; - assert_layout_linear("comment balloons", 120, doc, &opts); -} - -/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. -fn tracked_paras(n: usize) -> Document { - let mut d = Document::from_text("x"); - d.body = (0..n) - .map(|i| { - let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; - let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; - p.mark.size = Some(1.0); - p.props.space_before = Some(0.0); - p.props.space_after = Some(0.0); - wordcraft_doc::para_block(p) - }) - .collect(); - d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); - d -} - -/// Draws every item of the first page on its own, as the PDF export does. -fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { - let Some(p) = l.pages.first() else { return }; - let shown = display::ItemDisplay::new(d, p); - for it in p.header.iter().chain(&p.footer).chain(&p.items) { - std::hint::black_box(shown.draws(it, opts)); - } -} - -/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision -/// authors again for every item with a tracked change (lines x revisions). -#[test] -fn drawing_items_one_by_one_ranks_the_authors_once() { - let laid = |n: usize| { - let d = tracked_paras(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - assert_eq!(laid(SCALE * 40).1.pages.len(), 1); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); -} - -/// #3866: the same for the clipped cells, which each item's `page_display` collected again -/// (lines x clipped cells). -#[test] -fn drawing_items_one_by_one_collects_the_clipped_cells_once() { - let laid = |n: usize| { - let d = clipped_table(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - let opts = display::DisplayOptions::default(); - assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); -} - -/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. -#[test] -fn balloons_are_in_their_authors_colours() { - let mut d = Document::from_text("one two three"); - for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { - let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); - let id = u32::try_from(i).unwrap(); - d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); - let p = CharProps::default(); - d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); - d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); - } - let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); - // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. - let mut stripes: Vec<_> = l.pages[0] - .items - .iter() - .filter_map(|it| match it { - Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), - _ => None, - }) - .collect(); - stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); - let (ana, ben) = (display::revision_color(0), display::revision_color(1)); - assert_ne!(ana, ben); - assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); -} - -/// A page with tracked changes by several authors in its body and header, and exact-height -/// tables, one nested in a cell of another, whose cells clip their lines. -fn mixed_page() -> (Document, DocLayout) { - let mut d = tracked_paras(6); - let mut hp = Paragraph::with_text("head", CharProps::default()); - hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; - let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); - d.last_section.headers.default = Some(id); - let exact = |rows, cols| { - let mut t = Table::new(rows, cols, 200.0); - for row in &mut t.rows { - row.props.height = Some(0.5); - row.props.height_rule = HeightRule::Exact; - } - t - }; - // A cell with text in it holds a nested table. - let mut outer = exact(2, 2); - outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); - d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); - let l = lay(&d, &LayoutOptions::default()); - (d, l) -} - -/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the -/// page's authors and clipped cells were shared: a `page_display` of its own. -fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { - let mut items = vec![it.clone()]; - if matches!(it, Placed::Lines { .. }) { - let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); - items.extend(cells.cloned()); - } - let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; - display::page_display(d, &one, opts) -} - -/// #3866: drawing the items of a page one by one with the page's shared authors and clipped -/// cells gives what a `page_display` of each alone gave. -#[test] -fn drawing_items_one_by_one_matches_a_page_display_of_each() { - let (d, l) = mixed_page(); - let page = &l.pages[0]; - let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); - assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); - assert!(!page.header.is_empty(), "a header on the page"); - for markup in [true, false] { - let opts = display::DisplayOptions { markup, ..Default::default() }; - let shown = display::ItemDisplay::new(&d, page); - let (mut clips, mut coloured) = (0, 0); - for it in page.header.iter().chain(&page.footer).chain(&page.items) { - let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); - assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); - clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); - coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); - } - assert!(clips > 0, "some lines are clipped"); - assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); - } -} - -/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a -/// long document to find the first page that shows it. -#[test] -fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { - let laid = |n: usize| { - let mut d = Document::from_text("n"); - let huge = CharProps { size: Some(400.0), ..Default::default() }; - d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); - d.last_section.headers.default = Some(id); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); - (d, l, ends) - }; - let steps = |n: usize| { - let (d, l, (a, b)) = laid(n); - assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); - work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) - }; - let (small, big) = (steps(20), steps(160)); - eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); - assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); -} +//! Lookups that once rescanned the page, the paragraph or the document for every item they +//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. +//! +//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy +//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, +//! whose walk is the document model's, is timed. + +use super::tests::{SCALE, cost_ratio}; +use super::{ + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, + Placed, Rgb, SectionProps, SectionStart, StoryRef, display, layout, para, work, +}; +use wordcraft_doc::para::Run; +use wordcraft_doc::{Pos, Table}; + +fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { + layout(d, &mut LayoutCache::new(), opts) +} + +/// The most steps 8x the input may take, against the steps of the input: linear walks take +/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x. +const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64; + +/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on +/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted. +pub(crate) fn assert_linear_steps(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) { + let (small, big) = (make(n), make(SCALE * n)); + let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big))); + let ratio = b as f64 / s.max(1) as f64; + eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}"); + assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)"); + assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear"); +} + +/// [`assert_linear_steps`] for laying out `doc(n)`. +fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) { + assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts)))); +} + +/// The first laid-out body paragraph on page 0. +fn first_para(l: &DocLayout) -> Option<&ParaLayout> { + l.pages.first()?.items.iter().find_map(|it| match it { + Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()), + _ => None, + }) +} + +/// Finding 29: proofing text found each character's run from the paragraph's first run. +#[test] +fn proof_text_walks_the_runs_once() { + // One run per character, every other one deleted. + let para = |n: usize| { + let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default()); + p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect(); + p + }; + assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); + // The runs are the document model's, whose walk counts no steps, so this one is timed: the + // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for + // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over + // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. + let (small, big) = (para(50_000), para(SCALE * 50_000)); + let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); + let mut ratios = Vec::new(); + for _ in 0..3 { + let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); + eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + if ratio < 24.0 { + return; + } + ratios.push(ratio); + } + panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); +} + +/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. +fn bidi_hidden(n: usize) -> Document { + let head = "سلام "; + let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n))); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap(); + d +} + +/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret +/// offset from the line's first cluster. +#[test] +fn bidi_clicks_and_caret_steps_take_linear_time() { + let opts = LayoutOptions { show_hidden: false, ..Default::default() }; + let laid = |n: usize| lay(&bidi_hidden(n), &opts); + let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") }; + assert_eq!(pb, 1, "the hidden letters stay on one line"); + assert_linear_steps("bidi hit test", 5000, laid, |l| { + let Some(p) = first_para(l) else { panic!("no paragraph") }; + std::hint::black_box(p.off_at_x(0, 1.0)); + std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); + }); +} + +/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional +/// and left-to-right lines alike. +#[test] +fn line_carets_match_x_of() { + let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), { + let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end"); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d + }]; + for d in &docs { + for show_hidden in [false, true] { + let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() }); + let Some(p) = first_para(&l) else { panic!("no paragraph") }; + for li in 0..p.lines.len() { + let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect(); + assert!(!want.is_empty()); + assert_eq!(p.line_carets(li), want, "line {li}"); + } + } + } +} + +/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. +#[test] +fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { + // Rows with no cells, only a column skipped before and after them with a width: two + // spanning entries a row for next to no layout, so counting the grid per entry dominates. + let doc = |n: usize| { + let mut t = Table::new(n, 1, 468.0); + for row in t.rows.iter_mut().skip(1) { + row.cells.clear(); + row.props.grid_before = 1; + row.props.w_before = Some(10.0); + row.props.grid_after = 1; + row.props.w_after = Some(10.0); + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d + }; + assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default()); +} + +/// `n` continuous sections of an empty paragraph each, each with a different first page. The +/// first section names every header and footer; the others name them too, or (`inherit`) name +/// none and link to the previous section's. +fn sections(n: usize, inherit: bool) -> Document { + use wordcraft_doc::PartKind::{Footer, Header}; + let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1))); + let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); + let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)]; + let named = SectionProps { + start: SectionStart::Continuous, + title_page: true, + headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) }, + footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) }, + ..Default::default() + }; + let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() }; + for i in 0..n.saturating_sub(1) { + let s = if i == 0 || !inherit { named.clone() } else { linked.clone() }; + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); + } + d.last_section = if inherit { linked } else { named }; + d +} + +/// Finding 13: each section's headers and footers were found by walking back through the +/// sections before it, for every section and again for every page. Linked to the previous +/// section's, they must cost no more to lay out than named in each section. +#[test] +fn headers_inherited_through_many_sections_lay_out_in_linear_time() { + let l = lay(§ions(200, true), &LayoutOptions::default()); + assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); + let opts = LayoutOptions::default(); + assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts); + let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts)))); + let (named, inherited) = (steps(false), steps(true)); + assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}"); +} + +/// A header of `n` short paragraphs. +fn tall_header(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, paras); + d.last_section.headers.default = Some(id); + (d, id) +} + +/// Finding 28: selecting across a header looked through the whole page for each paragraph. +#[test] +fn selecting_a_long_header_takes_linear_time() { + // The header laid out, and the selection from its first paragraph to its last. + let laid = |n: usize| { + let (d, id) = tall_header(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 300); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph"); + assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + +/// An endnote of `n` paragraphs, a few lines each, continued over many pages. +fn long_endnote(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as + // the paragraphs and each paragraph costs next to nothing. + let huge = CharProps { size: Some(400.0), ..Default::default() }; + let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); + let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; + d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); + (d, id) +} + +/// #3866: selecting across an endnote over many pages probed the pages from the first for each +/// paragraph (paragraphs x pages). +#[test] +fn selecting_a_long_endnote_takes_linear_time() { + let laid = |n: usize| { + let (d, id) = long_endnote(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 20); + assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); + assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + +/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its +/// (empty) paragraph and the whole table fits on the first page. +fn clipped_table(n: usize) -> Document { + let mut t = Table::new(n, 60, 468.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d +} + +/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. +#[test] +fn drawing_many_clipped_cells_takes_linear_time() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count(); + assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); + assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl)))); +} + +/// `n` words, each inserted by its own author, at a size that fits them all on the first page. +fn tracked_words(n: usize) -> Document { + let mut d = Document::from_text("x"); + let p = Paragraph { + text: "word ".repeat(n), + runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(), + ..Paragraph::with_text("", CharProps::default()) + }; + d.body = vec![wordcraft_doc::para_block(p)]; + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. +#[test] +fn drawing_tracked_changes_by_many_authors_takes_linear_time() { + let laid = |n: usize| { + let d = tracked_words(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 1000).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + // Every run is drawn in its author's colour, so the authors are ranked at least once. + assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl)))); +} + +/// Each author keeps the colour of their first revision's place among the authors. +#[test] +fn tracked_changes_colour_by_author_in_order_of_appearance() { + let mut d = tracked_words(3); + // Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second. + for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) { + r.author = a.into(); + } + let l = lay(&d, &LayoutOptions::default()); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts) + .into_iter() + .filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None }) + .collect(); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(colours, [ana, ben, ana]); +} + +/// Finding 30 (#3706): every comment balloon ranked its author among all the comments. +#[test] +fn balloons_for_many_authors_lay_out_in_linear_time() { + let doc = |n: usize| { + let mut d = Document::from_text(&"word ".repeat(n)); + for i in (0..n).rev() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap(); + } + d + }; + let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; + assert_layout_linear("comment balloons", 120, doc, &opts); +} + +/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. +fn tracked_paras(n: usize) -> Document { + let mut d = Document::from_text("x"); + d.body = (0..n) + .map(|i| { + let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; + let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; + p.mark.size = Some(1.0); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + wordcraft_doc::para_block(p) + }) + .collect(); + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Draws every item of the first page on its own, as the PDF export does. +fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { + let Some(p) = l.pages.first() else { return }; + let shown = display::ItemDisplay::new(d, p); + for it in p.header.iter().chain(&p.footer).chain(&p.items) { + std::hint::black_box(shown.draws(it, opts)); + } +} + +/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision +/// authors again for every item with a tracked change (lines x revisions). +#[test] +fn drawing_items_one_by_one_ranks_the_authors_once() { + let laid = |n: usize| { + let d = tracked_paras(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 40).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: the same for the clipped cells, which each item's `page_display` collected again +/// (lines x clipped cells). +#[test] +fn drawing_items_one_by_one_collects_the_clipped_cells_once() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. +#[test] +fn balloons_are_in_their_authors_colours() { + let mut d = Document::from_text("one two three"); + for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); + } + let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); + // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. + let mut stripes: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), + _ => None, + }) + .collect(); + stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); +} + +/// A page with tracked changes by several authors in its body and header, and exact-height +/// tables, one nested in a cell of another, whose cells clip their lines. +fn mixed_page() -> (Document, DocLayout) { + let mut d = tracked_paras(6); + let mut hp = Paragraph::with_text("head", CharProps::default()); + hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let exact = |rows, cols| { + let mut t = Table::new(rows, cols, 200.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + t + }; + // A cell with text in it holds a nested table. + let mut outer = exact(2, 2); + outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); + let l = lay(&d, &LayoutOptions::default()); + (d, l) +} + +/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the +/// page's authors and clipped cells were shared: a `page_display` of its own. +fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { + let mut items = vec![it.clone()]; + if matches!(it, Placed::Lines { .. }) { + let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); + items.extend(cells.cloned()); + } + let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; + display::page_display(d, &one, opts) +} + +/// #3866: drawing the items of a page one by one with the page's shared authors and clipped +/// cells gives what a `page_display` of each alone gave. +#[test] +fn drawing_items_one_by_one_matches_a_page_display_of_each() { + let (d, l) = mixed_page(); + let page = &l.pages[0]; + let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); + assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); + assert!(!page.header.is_empty(), "a header on the page"); + for markup in [true, false] { + let opts = display::DisplayOptions { markup, ..Default::default() }; + let shown = display::ItemDisplay::new(&d, page); + let (mut clips, mut coloured) = (0, 0); + for it in page.header.iter().chain(&page.footer).chain(&page.items) { + let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); + clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); + coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); + } + assert!(clips > 0, "some lines are clipped"); + assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); + } +} + +/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a +/// long document to find the first page that shows it. +#[test] +fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { + let laid = |n: usize| { + let mut d = Document::from_text("n"); + let huge = CharProps { size: Some(400.0), ..Default::default() }; + d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); + d.last_section.headers.default = Some(id); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); + (d, l, ends) + }; + let steps = |n: usize| { + let (d, l, (a, b)) = laid(n); + assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); + work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) + }; + let (small, big) = (steps(20), steps(160)); + eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); + assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); +} From 056b18b714615d918a21774227b89a43ddd179cf Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:57:39 +0100 Subject: [PATCH 06/20] M7: match proofing pane geometry after rendered comparison Inspect the successful 20205a5 light/dark artifacts against the observed Word arrangement. Fill the sentence frame across the pane and bound the suggestion list to roughly three cards, retaining all results through scrolling and keeping ignore/dictionary actions below the list visible. The direct-correction/undo regression now clicks the first visible engine suggestion rather than relying on an offscreen fourth card. Update comparison/progress records. Normalize the baseline test file's line endings so its final diff contains only the two missing imports. Parent 20205a5 passed full CI, three-platform Printing/UI checks, targeted proofing tests and preview generation. This layout revision needs new hosted validation and inspected PNGs. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. --- ROADMAP.md | 4 +- crates/layout/src/tests_linear.rs | 988 ++++++++++++++-------------- crates/ui-egui/src/proofing_pane.rs | 101 +-- docs/gaps.md | 4 +- docs/spelling-review.md | 5 +- docs/ui-parity.md | 4 +- 6 files changed, 557 insertions(+), 549 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 95612af68..5d3be77a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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. @@ -644,7 +644,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 92c87c98d..f5b9f7809 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -1,494 +1,494 @@ -//! Lookups that once rescanned the page, the paragraph or the document for every item they -//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. -//! -//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy -//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, -//! whose walk is the document model's, is timed. - -use super::tests::{SCALE, cost_ratio}; -use super::{ - Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, - Placed, Rgb, SectionProps, SectionStart, StoryRef, display, layout, para, work, -}; -use wordcraft_doc::para::Run; -use wordcraft_doc::{Pos, Table}; - -fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { - layout(d, &mut LayoutCache::new(), opts) -} - -/// The most steps 8x the input may take, against the steps of the input: linear walks take -/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x. -const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64; - -/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on -/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted. -pub(crate) fn assert_linear_steps(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) { - let (small, big) = (make(n), make(SCALE * n)); - let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big))); - let ratio = b as f64 / s.max(1) as f64; - eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}"); - assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)"); - assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear"); -} - -/// [`assert_linear_steps`] for laying out `doc(n)`. -fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) { - assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts)))); -} - -/// The first laid-out body paragraph on page 0. -fn first_para(l: &DocLayout) -> Option<&ParaLayout> { - l.pages.first()?.items.iter().find_map(|it| match it { - Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()), - _ => None, - }) -} - -/// Finding 29: proofing text found each character's run from the paragraph's first run. -#[test] -fn proof_text_walks_the_runs_once() { - // One run per character, every other one deleted. - let para = |n: usize| { - let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default()); - p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect(); - p - }; - assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); - // The runs are the document model's, whose walk counts no steps, so this one is timed: the - // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for - // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over - // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. - let (small, big) = (para(50_000), para(SCALE * 50_000)); - let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); - let mut ratios = Vec::new(); - for _ in 0..3 { - let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); - eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - if ratio < 24.0 { - return; - } - ratios.push(ratio); - } - panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); -} - -/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. -fn bidi_hidden(n: usize) -> Document { - let head = "سلام "; - let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n))); - let at = Pos::body(0, 0); - d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); - d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap(); - d -} - -/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret -/// offset from the line's first cluster. -#[test] -fn bidi_clicks_and_caret_steps_take_linear_time() { - let opts = LayoutOptions { show_hidden: false, ..Default::default() }; - let laid = |n: usize| lay(&bidi_hidden(n), &opts); - let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") }; - assert_eq!(pb, 1, "the hidden letters stay on one line"); - assert_linear_steps("bidi hit test", 5000, laid, |l| { - let Some(p) = first_para(l) else { panic!("no paragraph") }; - std::hint::black_box(p.off_at_x(0, 1.0)); - std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); - }); -} - -/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional -/// and left-to-right lines alike. -#[test] -fn line_carets_match_x_of() { - let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), { - let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end"); - let at = Pos::body(0, 0); - d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); - d - }]; - for d in &docs { - for show_hidden in [false, true] { - let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() }); - let Some(p) = first_para(&l) else { panic!("no paragraph") }; - for li in 0..p.lines.len() { - let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect(); - assert!(!want.is_empty()); - assert_eq!(p.line_carets(li), want, "line {li}"); - } - } - } -} - -/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. -#[test] -fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { - // Rows with no cells, only a column skipped before and after them with a width: two - // spanning entries a row for next to no layout, so counting the grid per entry dominates. - let doc = |n: usize| { - let mut t = Table::new(n, 1, 468.0); - for row in t.rows.iter_mut().skip(1) { - row.cells.clear(); - row.props.grid_before = 1; - row.props.w_before = Some(10.0); - row.props.grid_after = 1; - row.props.w_after = Some(10.0); - } - let mut d = Document::from_text("x"); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); - d - }; - assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default()); -} - -/// `n` continuous sections of an empty paragraph each, each with a different first page. The -/// first section names every header and footer; the others name them too, or (`inherit`) name -/// none and link to the previous section's. -fn sections(n: usize, inherit: bool) -> Document { - use wordcraft_doc::PartKind::{Footer, Header}; - let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1))); - let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); - let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)]; - let named = SectionProps { - start: SectionStart::Continuous, - title_page: true, - headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) }, - footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) }, - ..Default::default() - }; - let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() }; - for i in 0..n.saturating_sub(1) { - let s = if i == 0 || !inherit { named.clone() } else { linked.clone() }; - d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); - } - d.last_section = if inherit { linked } else { named }; - d -} - -/// Finding 13: each section's headers and footers were found by walking back through the -/// sections before it, for every section and again for every page. Linked to the previous -/// section's, they must cost no more to lay out than named in each section. -#[test] -fn headers_inherited_through_many_sections_lay_out_in_linear_time() { - let l = lay(§ions(200, true), &LayoutOptions::default()); - assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); - let opts = LayoutOptions::default(); - assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts); - let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts)))); - let (named, inherited) = (steps(false), steps(true)); - assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}"); -} - -/// A header of `n` short paragraphs. -fn tall_header(n: usize) -> (Document, u32) { - let mut d = Document::from_text("Body"); - let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Header, paras); - d.last_section.headers.default = Some(id); - (d, id) -} - -/// Finding 28: selecting across a header looked through the whole page for each paragraph. -#[test] -fn selecting_a_long_header_takes_linear_time() { - // The header laid out, and the selection from its first paragraph to its last. - let laid = |n: usize| { - let (d, id) = tall_header(n); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); - (d, l, ends) - }; - let (d, l, (a, b)) = laid(SCALE * 300); - assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph"); - assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); -} - -/// An endnote of `n` paragraphs, a few lines each, continued over many pages. -fn long_endnote(n: usize) -> (Document, u32) { - let mut d = Document::from_text("Body"); - // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as - // the paragraphs and each paragraph costs next to nothing. - let huge = CharProps { size: Some(400.0), ..Default::default() }; - let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); - let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; - d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); - (d, id) -} - -/// #3866: selecting across an endnote over many pages probed the pages from the first for each -/// paragraph (paragraphs x pages). -#[test] -fn selecting_a_long_endnote_takes_linear_time() { - let laid = |n: usize| { - let (d, id) = long_endnote(n); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); - (d, l, ends) - }; - let (d, l, (a, b)) = laid(SCALE * 20); - assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); - assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); - assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); -} - -/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its -/// (empty) paragraph and the whole table fits on the first page. -fn clipped_table(n: usize) -> Document { - let mut t = Table::new(n, 60, 468.0); - for row in &mut t.rows { - row.props.height = Some(0.5); - row.props.height_rule = HeightRule::Exact; - } - let mut d = Document::from_text("x"); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); - d -} - -/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. -#[test] -fn drawing_many_clipped_cells_takes_linear_time() { - let laid = |n: usize| { - let d = clipped_table(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - let opts = display::DisplayOptions::default(); - let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); - let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count(); - assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); - assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl)))); -} - -/// `n` words, each inserted by its own author, at a size that fits them all on the first page. -fn tracked_words(n: usize) -> Document { - let mut d = Document::from_text("x"); - let p = Paragraph { - text: "word ".repeat(n), - runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(), - ..Paragraph::with_text("", CharProps::default()) - }; - d.body = vec![wordcraft_doc::para_block(p)]; - d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); - d -} - -/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. -#[test] -fn drawing_tracked_changes_by_many_authors_takes_linear_time() { - let laid = |n: usize| { - let d = tracked_words(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - assert_eq!(laid(SCALE * 1000).1.pages.len(), 1); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); - // Every run is drawn in its author's colour, so the authors are ranked at least once. - assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl)))); -} - -/// Each author keeps the colour of their first revision's place among the authors. -#[test] -fn tracked_changes_colour_by_author_in_order_of_appearance() { - let mut d = tracked_words(3); - // Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second. - for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) { - r.author = a.into(); - } - let l = lay(&d, &LayoutOptions::default()); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts) - .into_iter() - .filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None }) - .collect(); - let (ana, ben) = (display::revision_color(0), display::revision_color(1)); - assert_ne!(ana, ben); - assert_eq!(colours, [ana, ben, ana]); -} - -/// Finding 30 (#3706): every comment balloon ranked its author among all the comments. -#[test] -fn balloons_for_many_authors_lay_out_in_linear_time() { - let doc = |n: usize| { - let mut d = Document::from_text(&"word ".repeat(n)); - for i in (0..n).rev() { - let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); - let id = u32::try_from(i).unwrap(); - d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() }); - let p = CharProps::default(); - d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap(); - d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap(); - } - d - }; - let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; - assert_layout_linear("comment balloons", 120, doc, &opts); -} - -/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. -fn tracked_paras(n: usize) -> Document { - let mut d = Document::from_text("x"); - d.body = (0..n) - .map(|i| { - let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; - let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; - p.mark.size = Some(1.0); - p.props.space_before = Some(0.0); - p.props.space_after = Some(0.0); - wordcraft_doc::para_block(p) - }) - .collect(); - d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); - d -} - -/// Draws every item of the first page on its own, as the PDF export does. -fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { - let Some(p) = l.pages.first() else { return }; - let shown = display::ItemDisplay::new(d, p); - for it in p.header.iter().chain(&p.footer).chain(&p.items) { - std::hint::black_box(shown.draws(it, opts)); - } -} - -/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision -/// authors again for every item with a tracked change (lines x revisions). -#[test] -fn drawing_items_one_by_one_ranks_the_authors_once() { - let laid = |n: usize| { - let d = tracked_paras(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - assert_eq!(laid(SCALE * 40).1.pages.len(), 1); - let opts = display::DisplayOptions { markup: true, ..Default::default() }; - assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); -} - -/// #3866: the same for the clipped cells, which each item's `page_display` collected again -/// (lines x clipped cells). -#[test] -fn drawing_items_one_by_one_collects_the_clipped_cells_once() { - let laid = |n: usize| { - let d = clipped_table(n); - let l = lay(&d, &LayoutOptions::default()); - (d, l) - }; - let opts = display::DisplayOptions::default(); - assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); -} - -/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. -#[test] -fn balloons_are_in_their_authors_colours() { - let mut d = Document::from_text("one two three"); - for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { - let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); - let id = u32::try_from(i).unwrap(); - d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); - let p = CharProps::default(); - d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); - d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); - } - let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); - // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. - let mut stripes: Vec<_> = l.pages[0] - .items - .iter() - .filter_map(|it| match it { - Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), - _ => None, - }) - .collect(); - stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); - let (ana, ben) = (display::revision_color(0), display::revision_color(1)); - assert_ne!(ana, ben); - assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); -} - -/// A page with tracked changes by several authors in its body and header, and exact-height -/// tables, one nested in a cell of another, whose cells clip their lines. -fn mixed_page() -> (Document, DocLayout) { - let mut d = tracked_paras(6); - let mut hp = Paragraph::with_text("head", CharProps::default()); - hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; - let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); - d.last_section.headers.default = Some(id); - let exact = |rows, cols| { - let mut t = Table::new(rows, cols, 200.0); - for row in &mut t.rows { - row.props.height = Some(0.5); - row.props.height_rule = HeightRule::Exact; - } - t - }; - // A cell with text in it holds a nested table. - let mut outer = exact(2, 2); - outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); - d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); - d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); - let l = lay(&d, &LayoutOptions::default()); - (d, l) -} - -/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the -/// page's authors and clipped cells were shared: a `page_display` of its own. -fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { - let mut items = vec![it.clone()]; - if matches!(it, Placed::Lines { .. }) { - let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); - items.extend(cells.cloned()); - } - let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; - display::page_display(d, &one, opts) -} - -/// #3866: drawing the items of a page one by one with the page's shared authors and clipped -/// cells gives what a `page_display` of each alone gave. -#[test] -fn drawing_items_one_by_one_matches_a_page_display_of_each() { - let (d, l) = mixed_page(); - let page = &l.pages[0]; - let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); - assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); - assert!(!page.header.is_empty(), "a header on the page"); - for markup in [true, false] { - let opts = display::DisplayOptions { markup, ..Default::default() }; - let shown = display::ItemDisplay::new(&d, page); - let (mut clips, mut coloured) = (0, 0); - for it in page.header.iter().chain(&page.footer).chain(&page.items) { - let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); - assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); - clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); - coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); - } - assert!(clips > 0, "some lines are clipped"); - assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); - } -} - -/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a -/// long document to find the first page that shows it. -#[test] -fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { - let laid = |n: usize| { - let mut d = Document::from_text("n"); - let huge = CharProps { size: Some(400.0), ..Default::default() }; - d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); - let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); - d.last_section.headers.default = Some(id); - let l = lay(&d, &LayoutOptions::default()); - let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); - (d, l, ends) - }; - let steps = |n: usize| { - let (d, l, (a, b)) = laid(n); - assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); - work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) - }; - let (small, big) = (steps(20), steps(160)); - eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); - assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); -} +//! Lookups that once rescanned the page, the paragraph or the document for every item they +//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. +//! +//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy +//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, +//! whose walk is the document model's, is timed. + +use super::tests::{SCALE, cost_ratio}; +use super::{ + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, + Placed, Rgb, SectionProps, SectionStart, StoryRef, display, layout, para, work, +}; +use wordcraft_doc::para::Run; +use wordcraft_doc::{Pos, Table}; + +fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { + layout(d, &mut LayoutCache::new(), opts) +} + +/// The most steps 8x the input may take, against the steps of the input: linear walks take +/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x. +const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64; + +/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on +/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted. +pub(crate) fn assert_linear_steps(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) { + let (small, big) = (make(n), make(SCALE * n)); + let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big))); + let ratio = b as f64 / s.max(1) as f64; + eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}"); + assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)"); + assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear"); +} + +/// [`assert_linear_steps`] for laying out `doc(n)`. +fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) { + assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts)))); +} + +/// The first laid-out body paragraph on page 0. +fn first_para(l: &DocLayout) -> Option<&ParaLayout> { + l.pages.first()?.items.iter().find_map(|it| match it { + Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()), + _ => None, + }) +} + +/// Finding 29: proofing text found each character's run from the paragraph's first run. +#[test] +fn proof_text_walks_the_runs_once() { + // One run per character, every other one deleted. + let para = |n: usize| { + let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default()); + p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect(); + p + }; + assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); + // The runs are the document model's, whose walk counts no steps, so this one is timed: the + // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for + // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over + // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. + let (small, big) = (para(50_000), para(SCALE * 50_000)); + let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); + let mut ratios = Vec::new(); + for _ in 0..3 { + let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); + eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + if ratio < 24.0 { + return; + } + ratios.push(ratio); + } + panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); +} + +/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. +fn bidi_hidden(n: usize) -> Document { + let head = "سلام "; + let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n))); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap(); + d +} + +/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret +/// offset from the line's first cluster. +#[test] +fn bidi_clicks_and_caret_steps_take_linear_time() { + let opts = LayoutOptions { show_hidden: false, ..Default::default() }; + let laid = |n: usize| lay(&bidi_hidden(n), &opts); + let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") }; + assert_eq!(pb, 1, "the hidden letters stay on one line"); + assert_linear_steps("bidi hit test", 5000, laid, |l| { + let Some(p) = first_para(l) else { panic!("no paragraph") }; + std::hint::black_box(p.off_at_x(0, 1.0)); + std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); + }); +} + +/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional +/// and left-to-right lines alike. +#[test] +fn line_carets_match_x_of() { + let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), { + let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end"); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d + }]; + for d in &docs { + for show_hidden in [false, true] { + let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() }); + let Some(p) = first_para(&l) else { panic!("no paragraph") }; + for li in 0..p.lines.len() { + let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect(); + assert!(!want.is_empty()); + assert_eq!(p.line_carets(li), want, "line {li}"); + } + } + } +} + +/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. +#[test] +fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { + // Rows with no cells, only a column skipped before and after them with a width: two + // spanning entries a row for next to no layout, so counting the grid per entry dominates. + let doc = |n: usize| { + let mut t = Table::new(n, 1, 468.0); + for row in t.rows.iter_mut().skip(1) { + row.cells.clear(); + row.props.grid_before = 1; + row.props.w_before = Some(10.0); + row.props.grid_after = 1; + row.props.w_after = Some(10.0); + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d + }; + assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default()); +} + +/// `n` continuous sections of an empty paragraph each, each with a different first page. The +/// first section names every header and footer; the others name them too, or (`inherit`) name +/// none and link to the previous section's. +fn sections(n: usize, inherit: bool) -> Document { + use wordcraft_doc::PartKind::{Footer, Header}; + let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1))); + let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); + let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)]; + let named = SectionProps { + start: SectionStart::Continuous, + title_page: true, + headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) }, + footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) }, + ..Default::default() + }; + let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() }; + for i in 0..n.saturating_sub(1) { + let s = if i == 0 || !inherit { named.clone() } else { linked.clone() }; + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); + } + d.last_section = if inherit { linked } else { named }; + d +} + +/// Finding 13: each section's headers and footers were found by walking back through the +/// sections before it, for every section and again for every page. Linked to the previous +/// section's, they must cost no more to lay out than named in each section. +#[test] +fn headers_inherited_through_many_sections_lay_out_in_linear_time() { + let l = lay(§ions(200, true), &LayoutOptions::default()); + assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); + let opts = LayoutOptions::default(); + assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts); + let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts)))); + let (named, inherited) = (steps(false), steps(true)); + assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}"); +} + +/// A header of `n` short paragraphs. +fn tall_header(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, paras); + d.last_section.headers.default = Some(id); + (d, id) +} + +/// Finding 28: selecting across a header looked through the whole page for each paragraph. +#[test] +fn selecting_a_long_header_takes_linear_time() { + // The header laid out, and the selection from its first paragraph to its last. + let laid = |n: usize| { + let (d, id) = tall_header(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 300); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph"); + assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + +/// An endnote of `n` paragraphs, a few lines each, continued over many pages. +fn long_endnote(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as + // the paragraphs and each paragraph costs next to nothing. + let huge = CharProps { size: Some(400.0), ..Default::default() }; + let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); + let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; + d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); + (d, id) +} + +/// #3866: selecting across an endnote over many pages probed the pages from the first for each +/// paragraph (paragraphs x pages). +#[test] +fn selecting_a_long_endnote_takes_linear_time() { + let laid = |n: usize| { + let (d, id) = long_endnote(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 20); + assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); + assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + +/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its +/// (empty) paragraph and the whole table fits on the first page. +fn clipped_table(n: usize) -> Document { + let mut t = Table::new(n, 60, 468.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d +} + +/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. +#[test] +fn drawing_many_clipped_cells_takes_linear_time() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count(); + assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); + assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl)))); +} + +/// `n` words, each inserted by its own author, at a size that fits them all on the first page. +fn tracked_words(n: usize) -> Document { + let mut d = Document::from_text("x"); + let p = Paragraph { + text: "word ".repeat(n), + runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(), + ..Paragraph::with_text("", CharProps::default()) + }; + d.body = vec![wordcraft_doc::para_block(p)]; + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. +#[test] +fn drawing_tracked_changes_by_many_authors_takes_linear_time() { + let laid = |n: usize| { + let d = tracked_words(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 1000).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + // Every run is drawn in its author's colour, so the authors are ranked at least once. + assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl)))); +} + +/// Each author keeps the colour of their first revision's place among the authors. +#[test] +fn tracked_changes_colour_by_author_in_order_of_appearance() { + let mut d = tracked_words(3); + // Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second. + for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) { + r.author = a.into(); + } + let l = lay(&d, &LayoutOptions::default()); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts) + .into_iter() + .filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None }) + .collect(); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(colours, [ana, ben, ana]); +} + +/// Finding 30 (#3706): every comment balloon ranked its author among all the comments. +#[test] +fn balloons_for_many_authors_lay_out_in_linear_time() { + let doc = |n: usize| { + let mut d = Document::from_text(&"word ".repeat(n)); + for i in (0..n).rev() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap(); + } + d + }; + let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; + assert_layout_linear("comment balloons", 120, doc, &opts); +} + +/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. +fn tracked_paras(n: usize) -> Document { + let mut d = Document::from_text("x"); + d.body = (0..n) + .map(|i| { + let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; + let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; + p.mark.size = Some(1.0); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + wordcraft_doc::para_block(p) + }) + .collect(); + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Draws every item of the first page on its own, as the PDF export does. +fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { + let Some(p) = l.pages.first() else { return }; + let shown = display::ItemDisplay::new(d, p); + for it in p.header.iter().chain(&p.footer).chain(&p.items) { + std::hint::black_box(shown.draws(it, opts)); + } +} + +/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision +/// authors again for every item with a tracked change (lines x revisions). +#[test] +fn drawing_items_one_by_one_ranks_the_authors_once() { + let laid = |n: usize| { + let d = tracked_paras(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 40).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: the same for the clipped cells, which each item's `page_display` collected again +/// (lines x clipped cells). +#[test] +fn drawing_items_one_by_one_collects_the_clipped_cells_once() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. +#[test] +fn balloons_are_in_their_authors_colours() { + let mut d = Document::from_text("one two three"); + for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); + } + let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); + // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. + let mut stripes: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), + _ => None, + }) + .collect(); + stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); +} + +/// A page with tracked changes by several authors in its body and header, and exact-height +/// tables, one nested in a cell of another, whose cells clip their lines. +fn mixed_page() -> (Document, DocLayout) { + let mut d = tracked_paras(6); + let mut hp = Paragraph::with_text("head", CharProps::default()); + hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let exact = |rows, cols| { + let mut t = Table::new(rows, cols, 200.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + t + }; + // A cell with text in it holds a nested table. + let mut outer = exact(2, 2); + outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); + let l = lay(&d, &LayoutOptions::default()); + (d, l) +} + +/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the +/// page's authors and clipped cells were shared: a `page_display` of its own. +fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { + let mut items = vec![it.clone()]; + if matches!(it, Placed::Lines { .. }) { + let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); + items.extend(cells.cloned()); + } + let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; + display::page_display(d, &one, opts) +} + +/// #3866: drawing the items of a page one by one with the page's shared authors and clipped +/// cells gives what a `page_display` of each alone gave. +#[test] +fn drawing_items_one_by_one_matches_a_page_display_of_each() { + let (d, l) = mixed_page(); + let page = &l.pages[0]; + let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); + assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); + assert!(!page.header.is_empty(), "a header on the page"); + for markup in [true, false] { + let opts = display::DisplayOptions { markup, ..Default::default() }; + let shown = display::ItemDisplay::new(&d, page); + let (mut clips, mut coloured) = (0, 0); + for it in page.header.iter().chain(&page.footer).chain(&page.items) { + let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); + clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); + coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); + } + assert!(clips > 0, "some lines are clipped"); + assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); + } +} + +/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a +/// long document to find the first page that shows it. +#[test] +fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { + let laid = |n: usize| { + let mut d = Document::from_text("n"); + let huge = CharProps { size: Some(400.0), ..Default::default() }; + d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); + d.last_section.headers.default = Some(id); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); + (d, l, ends) + }; + let steps = |n: usize| { + let (d, l, (a, b)) = laid(n); + assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); + work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) + }; + let (small, big) = (steps(20), steps(160)); + eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); + assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); +} diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index 2e82349ff..8eb37ef32 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -103,7 +103,9 @@ fn action_link(ui: &mut Ui, text: &str) -> bool { fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bool) { let t = Tokens::get(ui.ctx()); + let width = ui.available_width(); egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).inner_margin(8).show(ui, |ui| { + ui.set_min_width((width - 18.0).max(0.0)); ui.horizontal(|ui| { let mut job = egui::text::LayoutJob::default(); let format = egui::TextFormat { font_id: regular(12.0), color: t.text, ..Default::default() }; @@ -157,56 +159,58 @@ fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { } else { egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - for (index, suggestion) in suggestions.iter().enumerate() { - ui.push_id(index, |ui| { - if index > 0 { - ui.separator(); - } - ui.horizontal(|ui| { - let width = (ui.available_width() - 34.0).max(20.0); - let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, suggestion)); - if response.hovered() { - ui.painter().rect_filled(rect, 0.0, t.hover); + egui::ScrollArea::vertical().id_salt("proofing_suggestions").max_height(210.0).show(ui, |ui| { + for (index, suggestion) in suggestions.iter().enumerate() { + ui.push_id(index, |ui| { + if index > 0 { + ui.separator(); } - ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text( - rect.left_center() + vec2(12.0, 0.0), - Align2::LEFT_CENTER, - suggestion, - semibold(12.5), - t.text, - ); - if response.clicked() { - change = Some(suggestion.clone()); - } - ui.menu_button("⌄", |ui| { - if ui.button(tl!("Change…")).clicked() { - form.replacement.clone_from(suggestion); - form.manual = true; - ui.close(); + ui.horizontal(|ui| { + let width = (ui.available_width() - 34.0).max(20.0); + let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, suggestion)); + if response.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover); } - if spelling && ui.button(tl!("Add to AutoCorrect")).clicked() { - let word = form.issue.get("text").and_then(Value::as_str).unwrap_or(""); - let params = json!({"add": {"from": word, "to": suggestion}}); - if form.current(app) { - if let Err(e) = app.run("tools.autocorrect", params) { - form.message = e; - } - } else { - form.message = tl!("The document or selection changed. Check again to continue.").into(); + ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text( + rect.left_center() + vec2(12.0, 0.0), + Align2::LEFT_CENTER, + suggestion, + semibold(12.5), + t.text, + ); + if response.clicked() { + change = Some(suggestion.clone()); + } + ui.menu_button("⌄", |ui| { + if ui.button(tl!("Change…")).clicked() { + form.replacement.clone_from(suggestion); + form.manual = true; + ui.close(); } - ui.close(); - } - if ui.button(tl!("AutoCorrect Options…")).clicked() { - let _ = app.run("tools.autocorrect", json!({})); - ui.close(); - } - }) - .response - .on_hover_text(tl!("More options")); + if spelling && ui.button(tl!("Add to AutoCorrect")).clicked() { + let word = form.issue.get("text").and_then(Value::as_str).unwrap_or(""); + let params = json!({"add": {"from": word, "to": suggestion}}); + if form.current(app) { + if let Err(e) = app.run("tools.autocorrect", params) { + form.message = e; + } + } else { + form.message = tl!("The document or selection changed. Check again to continue.").into(); + } + ui.close(); + } + if ui.button(tl!("AutoCorrect Options…")).clicked() { + let _ = app.run("tools.autocorrect", json!({})); + ui.close(); + } + }) + .response + .on_hover_text(tl!("More options")); + }); }); - }); - } + } + }); }); } if let Some(replacement) = change { @@ -276,9 +280,10 @@ mod tests { fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() { let mut h = harness("The quick brwn fox."); assert!(!h.state().canvas.context_menu_open); - h.get_by_label("brown").click(); + let replacement = form(h.state()).issue["suggestions"][0].as_str().unwrap().to_string(); + h.get_by_label(&replacement).click(); h.run_steps(3); - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox."); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), format!("The quick {replacement} fox.")); assert!(form(h.state()).done()); h.get_by_label("Close").click(); h.run_steps(2); diff --git a/docs/gaps.md b/docs/gaps.md index 3fe0a67e2..1e1616b0b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -150,7 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/spelling-review.md b/docs/spelling-review.md index 5c5668f65..c9ed3fe1e 100644 --- a/docs/spelling-review.md +++ b/docs/spelling-review.md @@ -1,6 +1,6 @@ # Spelling and grammar task pane -> **Last updated:** 2026-10-26 · **Status:** implementation proposed; hosted checks and rendered comparison remain required before integration. +> **Last updated:** 2026-10-26 · **Status:** first hosted gate and three-platform UI checks passed; rendered comparison found layout gaps now being corrected and revalidated. ## Word reference @@ -25,9 +25,12 @@ This work is independent of French contribution #3185. It consumes language-awar - Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. - Required gate: cargo xtask ci. Cargo is unavailable on the preparation PC; hosted validation is required, and test definitions alone do not establish success. +The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. A new preview and hosted gate must validate this revision; exact pixel parity is not claimed. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Inspect first successful light/dark artifacts; fill the sentence frame and bound suggestion-list height, retaining all suggestions through scrolling | | 2026-10-26 | minor | Actual Word for Windows observation replaces the modal proposal with a right-side Proofing pane, sentence context and directly clickable suggestion cards | | 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7c7a29fa8..1ff1cb282 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-26 · **Change:** minor (Word-observed spelling and grammar task pane); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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). @@ -88,7 +88,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | From 057350ef6ec9d966d7e224b41fdef144100aee65 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:32 +0100 Subject: [PATCH 07/20] M7: keep the proofing Read Aloud control at the context edge The 056b18b preview confirms full-width context and visible actions, but the text child still shrinks to its contents and pulls the Read Aloud control inward. Keep that child at its allocated width so the control aligns at the right edge as observed in Word. All five workflows passed at parent 056b18b; both light/dark artifacts were downloaded and inspected. This alignment revision needs new hosted checks and preview inspection. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. --- crates/ui-egui/src/proofing_pane.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index 8eb37ef32..e63f9e018 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -113,7 +113,9 @@ fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bo let marked = egui::TextFormat { underline: Stroke::new(1.0, if spelling { t.red } else { t.blue }), ..format.clone() }; job.append(form.issue.get("text").and_then(Value::as_str).unwrap_or(""), 0.0, marked); job.append(&form.context_after, 0.0, format); - ui.allocate_ui_with_layout(vec2((ui.available_width() - 36.0).max(20.0), 30.0), egui::Layout::top_down(Align::Min), |ui| { + let text_width = (ui.available_width() - 36.0).max(20.0); + ui.allocate_ui_with_layout(vec2(text_width, 30.0), egui::Layout::top_down(Align::Min), |ui| { + ui.set_min_width(text_width); ui.add(egui::Label::new(job).wrap()); }); let (rect, response) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); From 62f905084852b4d24381a776c9f605a1264f2ca6 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:53:47 +0100 Subject: [PATCH 08/20] M7: resolve exact proofing selections and address pane review Give the exact selected issue priority over overlapping grammar when querying the focus, including reversed selections. Keep point lookup for explicit other positions. Add command and pane regressions for overlapping repeated misspellings, correction and undo. Include command/window/document/canvas integration paths in preview workflow triggers. Preserve historical three-platform evidence while recording f126's successful gate/previews/Windows/Linux and outstanding macOS validation after pre-run cancellations. These address the three CodeRabbit findings on official #4471. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. New hosted checks must validate this SHA before any claim of success. --- .github/workflows/spelling-review.yml | 5 ++++ ROADMAP.md | 4 +-- crates/engine/src/cmd/review.rs | 36 ++++++++++++++++++++++++++- crates/ui-egui/src/proofing_pane.rs | 16 ++++++++++++ docs/gaps.md | 4 +-- docs/spelling-review.md | 7 +++--- docs/ui-parity.md | 4 +-- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml index a3ead7ce3..df2cfa951 100644 --- a/.github/workflows/spelling-review.yml +++ b/.github/workflows/spelling-review.yml @@ -5,6 +5,11 @@ on: paths: - 'crates/ui-egui/src/proofing_pane.rs' - 'crates/ui-egui/src/panes.rs' + - 'crates/ui-egui/src/lib.rs' + - 'crates/ui-egui/src/window_ui.rs' + - 'crates/ui-egui/src/control.rs' + - 'crates/ui-egui/src/documents.rs' + - 'crates/ui-egui/src/canvas.rs' - 'crates/engine/src/cmd/review.rs' - '.github/workflows/spelling-review.yml' workflow_dispatch: diff --git a/ROADMAP.md b/ROADMAP.md index 5d3be77a1..54a5ddb16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered layout and overlapping-issue selection review); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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. @@ -644,7 +644,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 873c7f674..bdfdd2711 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -976,7 +976,16 @@ fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec< /// The issue under a position. fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)> { - para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off) + let issues = para_issues(s, at.story, &at.path); + let selected = s.sel.ordered(); + // F7 can select a spelling issue inside a larger grammar issue. Keep that + // exact target for either selection direction before falling back to a point. + if at == &s.sel.focus + && let Some(issue) = issues.iter().find(|(a, b, _)| a == &selected.0 && b == &selected.1) + { + return Some(issue.clone()); + } + issues.into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off) } fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value { @@ -2275,4 +2284,29 @@ mod spelling_navigation_tests { assert_eq!(s.run("review.spelling", &json!({})).unwrap()["start"], first["start"]); } } + #[test] + fn spelling_review_dialog_navigation_resolves_selected_spelling_before_overlapping_grammar() { + for reverse in [false, true] { + let mut s = Session::new(Document::from_text("A sentense sentense.")); + s.sel = Selection::caret(Pos::body(0, 11)); + let selected = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(selected["kind"], "spelling"); + assert_eq!(selected["start"]["off"], 11); + assert_eq!(selected["text"], "sentense"); + let issues = s.run("review.issues", &json!({})).unwrap(); + assert!(issues.as_array().unwrap().iter().any(|i| i["kind"] == "grammar" && i["text"] == "sentense sentense")); + if reverse { + std::mem::swap(&mut s.sel.anchor, &mut s.sel.focus); + } + assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected); + let elsewhere = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": 5}})).unwrap(); + assert_eq!(elsewhere["kind"], "spelling"); + assert_eq!(elsewhere["start"]["off"], 2, "an explicit other position keeps point lookup"); + s.run("review.applySuggestion", &json!({"text": "sentence"})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentence."); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentense."); + } + } + } diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index e63f9e018..91cf5dac0 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -325,6 +325,22 @@ mod tests { } } + #[test] + fn spelling_review_dialog_selected_spelling_stays_actionable_when_grammar_overlaps() { + let mut a = app("A sentense sentense."); + a.session.sel = Selection::caret(Pos::body(0, 11)); + a.run("review.spelling", json!({"wrap": false})).unwrap(); + let mut f = a.spelling_review.take().unwrap(); + assert_eq!(f.issue["kind"], "spelling"); + assert!(f.current(&mut a), "the selected typo is current despite overlapping grammar"); + f.replacement = "sentence".into(); + f.act(&mut a, Some("review.applySuggestion")); + assert!(f.message.is_empty()); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentence."); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentense."); + } + #[test] fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() { for replace in [false, true] { diff --git a/docs/gaps.md b/docs/gaps.md index 1e1616b0b..5fd16566e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered layout and overlapping-issue selection review); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -150,7 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/spelling-review.md b/docs/spelling-review.md index c9ed3fe1e..dd695e9b4 100644 --- a/docs/spelling-review.md +++ b/docs/spelling-review.md @@ -1,6 +1,6 @@ # Spelling and grammar task pane -> **Last updated:** 2026-10-26 · **Status:** first hosted gate and three-platform UI checks passed; rendered comparison found layout gaps now being corrected and revalidated. +> **Last updated:** 2026-10-26 · **Status:** historical three-platform checks passed; at f1264f3 the revised hosted gate, previews, Windows and Linux passed, while macOS remained unvalidated after pre-run cancellations. Overlap-review corrections now require new hosted checks. ## Word reference @@ -20,17 +20,18 @@ This work is independent of French contribution #3185. It consumes language-awar ## Verification -- Engine: one-/two-paragraph navigation, forward-only completion and explicit wrapping. +- Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit-position fallback, correction and undo. - UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching. - Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. - Required gate: cargo xtask ci. Cargo is unavailable on the preparation PC; hosted validation is required, and test definitions alone do not establish success. -The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. A new preview and hosted gate must validate this revision; exact pixel parity is not claimed. +The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. At `f1264f3`, the revised light/dark previews and full hosted gate passed; Windows and Linux UI checks passed, while macOS remained unvalidated after pre-run cancellations. Both PNGs were inspected, including the right-aligned Read Aloud control. The following review revision gives the exact selected spelling range priority over an overlapping grammar issue and requires new hosted checks. Exact pixel parity is not claimed. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Resolve selected spelling before overlapping grammar, add command/pane regressions, include pane-integration workflow paths and distinguish validated history from pending review corrections | | 2026-10-26 | minor | Inspect first successful light/dark artifacts; fill the sentence frame and bound suggestion-list height, retaining all suggestions through scrolling | | 2026-10-26 | minor | Actual Word for Windows observation replaces the modal proposal with a right-side Proofing pane, sentence context and directly clickable suggestion cards | | 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1ff1cb282..bf3999acc 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-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered comparison adjusts context width and suggestion scrolling); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Word-observed spelling and grammar task pane; rendered layout and overlapping-issue selection review); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously 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). @@ -88,7 +88,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared | Date | Change | Summary | |---|---|---| -| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; readiness estimates unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | From 0cd06c786c2ac1194674744bf6f3abac773676bf Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:57:22 +0100 Subject: [PATCH 09/20] M7: normalize regression module spacing Remove the extra blank line before the new navigation regression module's closing brace. No behavior change. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. Hosted checks must validate this new SHA. --- crates/engine/src/cmd/review.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index bdfdd2711..2d3780528 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -2308,5 +2308,4 @@ mod spelling_navigation_tests { assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentense."); } } - } From 55dce8eebfeb453ebfb2a7d812de8895340fc5f8 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:13:05 +0100 Subject: [PATCH 10/20] ci: avoid persisting proofing-preview checkout credentials Set persist-credentials: false for preview tests and join the two newly added documentation blockquotes as requested by review. No Rust source change. Validation: independently reviewed exact workflow and Markdown diffs and tree hashes. Cargo fmt passed on the unchanged Rust source; cargo xtask ci was attempted but blocked by missing local dlltool.exe. Fresh hosted checks are required for this revision. --- .github/workflows/spelling-review.yml | 2 ++ ROADMAP.md | 2 +- docs/gaps.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml index c23a77c03..3adc7cdc8 100644 --- a/.github/workflows/spelling-review.yml +++ b/.github/workflows/spelling-review.yml @@ -37,6 +37,8 @@ jobs: CARGO_INCREMENTAL: '0' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - name: Install build and software-rendering dependencies run: | sudo apt-get update diff --git a/ROADMAP.md b/ROADMAP.md index 9a7fdd2d7..fee18576d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -641,7 +641,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps ## Revision history > 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. - +> > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. | Date | Change | Summary | diff --git a/docs/gaps.md b/docs/gaps.md index 42f27873c..1f6a72223 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -143,7 +143,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): ## Revision history > 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. - +> > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. > 2026-10-23 · cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison. From 6e96b5470f448a51b7a7a9442886a6af5c9d77df Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:44:42 +0100 Subject: [PATCH 11/20] PROOF.API: preserve explicit-position suggestion lookup --- crates/engine/src/cmd/review/proofing.rs | 9 +++++++-- crates/engine/src/cmd/review/tests.rs | 16 ++++++++++++++++ docs/spelling-review.md | 11 ++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/review/proofing.rs b/crates/engine/src/cmd/review/proofing.rs index 14694278e..2b3a2e4a9 100644 --- a/crates/engine/src/cmd/review/proofing.rs +++ b/crates/engine/src/cmd/review/proofing.rs @@ -120,8 +120,13 @@ pub(super) fn all_issues(s: &mut Session, _: &Value) -> CmdResult { } pub(super) fn suggestions(s: &mut Session, v: &Value) -> CmdResult { - let at = v.get("pos").and_then(crate::cmd::parse_pos).unwrap_or_else(|| s.sel.focus.clone()); - match issue_at(s, &at) { + // Explicit positions are point lookups, even when they equal the selection focus. + // Only an implicit or unparseable position keeps the exact selected issue. + let issue = match v.get("pos").and_then(crate::cmd::parse_pos) { + Some(at) => para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off), + None => issue_at(s, &s.sel.focus), + }; + match issue { Some((a, b, i)) => Ok(issue_json(s, &a, &b, &i)), None => Ok(Value::Null), } diff --git a/crates/engine/src/cmd/review/tests.rs b/crates/engine/src/cmd/review/tests.rs index db350deea..64dd98397 100644 --- a/crates/engine/src/cmd/review/tests.rs +++ b/crates/engine/src/cmd/review/tests.rs @@ -1095,6 +1095,22 @@ mod spelling_navigation_tests { std::mem::swap(&mut s.sel.anchor, &mut s.sel.focus); } assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected); + let before_lookup = s.sel.clone(); + let focus = s.sel.focus.off; + let explicit = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": focus}})).unwrap(); + assert_eq!(explicit["kind"], "grammar", "an explicit focus is a point lookup in either selection direction"); + assert_eq!(explicit["text"], "sentense sentense"); + assert_eq!(explicit["start"]["off"], 2); + assert_eq!(explicit["end"]["off"], 19); + assert_eq!(s.sel, before_lookup, "suggestion lookups do not move the selection"); + for pos in [serde_json::Value::Null, json!({"off": "invalid"})] { + assert_eq!( + s.run("review.suggestions", &json!({"pos": pos})).unwrap(), + selected, + "unparseable positions preserve implicit selection lookup" + ); + } + assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected); let elsewhere = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": 5}})).unwrap(); assert_eq!(elsewhere["kind"], "spelling"); assert_eq!(elsewhere["start"]["off"], 2, "an explicit other position keeps point lookup"); diff --git a/docs/spelling-review.md b/docs/spelling-review.md index 219cced51..968a79e0b 100644 --- a/docs/spelling-review.md +++ b/docs/spelling-review.md @@ -1,6 +1,6 @@ # Spelling and grammar task pane -> **Last updated:** 2026-10-27 · **Status:** technical synchronization with main `436d444` prepared; validation on the new merge SHA is pending. Historical status: historical three-platform checks passed; at f1264f3 the revised hosted gate, previews, Windows and Linux passed, while macOS remained unvalidated after pre-run cancellations. Overlap-review corrections now require new hosted checks. +> **Last updated:** 2026-10-30 · **Status:** synchronized with main `3b7f7ed`; explicit-position API follow-up prepared, with validation of its new SHA pending. Historical status: historical three-platform checks passed; at f1264f3 the revised hosted gate, previews, Windows and Linux passed, while macOS remained unvalidated after pre-run cancellations. Overlap-review corrections now require new hosted checks. ## Word reference @@ -18,16 +18,20 @@ If another command changes the document body or selection, a cached suggestion i This work is independent of French contribution #3185. It consumes language-aware engine results and adds neither dictionaries nor grammar rules. Labels use the external shared translation lookup with English fallback for missing keys. No release date or readiness increase is implied. +## Technical synchronization and API follow-up (2026-10-30) + +The existing pane is merged with main `3b7f7ed10b2eb0ae3c39b71551d36ed2df1d1d8b`, retaining the upstream menus, pane host and per-window state. For `review.suggestions`, a successfully parsed explicit `pos` is a pure point lookup even when it equals the selection focus; an absent or unparseable `pos` keeps exact-selection priority. The follow-up preserves correction and undo; fresh hosted checks must validate its new SHA. Results for its parent `c077c155` do not validate the follow-up. + ## Technical synchronization (2026-10-27) The language-independent pane is rebased by a three-way merge with main `436d444ef0ee35c2e2b9fb44f87aa7d08d72641f`. Engine navigation and exact selected-range lookup live in `crates/engine/src/cmd/review/proofing.rs`; the existing regressions follow into `review/tests.rs`. The UI command hook follows into `app/commands.rs`, construction into `app.rs`, inspection into `control/handler.rs`, and pane state/closure into `panes/state.rs` and `panes/host.rs`. The upstream context-menu and dropdown implementations stay intact. Workflow path filters cover the extracted modules while retaining historical paths. No dictionary, grammar rule, new feature or readiness increase is introduced by this synchronization. ## Verification -- Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit-position fallback, correction and undo. +- Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit point lookup at the focus, absent/unparseable-position selection fallback, unchanged selection, correction and undo. - UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching. - Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. -- Required gate: `cargo xtask ci`, plus the proofing preview workflow. No compilation or test was run in this resolution staging; the coordinator must validate the new merge SHA. Earlier hosted results do not validate this synchronization. +- Required gate: `cargo xtask ci`, plus the existing proofing preview workflow. Local formatting passed; the full gate was attempted but the offline cache lacks the locked `craft-update` dependency from craft-libs `04aa1703`. No local compilation or test success is claimed. The coordinator must validate the follow-up SHA; earlier hosted results validate their own SHAs only. The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. At `f1264f3`, the revised light/dark previews and full hosted gate passed; Windows and Linux UI checks passed, while macOS remained unvalidated after pre-run cancellations. Both PNGs were inspected, including the right-aligned Read Aloud control. The following review revision gives the exact selected spelling range priority over an overlapping grammar issue and requires new hosted checks. Exact pixel parity is not claimed. @@ -35,6 +39,7 @@ The first light/dark artifacts at `20205a5` were inspected: the sentence frame s | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Merge the existing pane with main `3b7f7ed`; preserve explicit-position point lookup at the selection focus, implicit/parse-fallback selection priority and correction/undo; new follow-up SHA requires fresh hosted validation | | 2026-10-27 | trivial | Synchronize the existing pane with extracted engine/UI modules on main `436d444`; preserve selection/navigation/undo tests and compact menus, expand workflow path filters; new-SHA validation pending | | 2026-10-26 | minor | Resolve selected spelling before overlapping grammar, add command/pane regressions, include pane-integration workflow paths and distinguish validated history from pending review corrections | | 2026-10-26 | minor | Inspect first successful light/dark artifacts; fill the sentence frame and bound suggestion-list height, retaining all suggestions through scrolling | From 8686fa45ec7e63d076aef512871420cd4e235cb2 Mon Sep 17 00:00:00 2001 From: totoz64 <47389794+totoz64@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:53:38 +0100 Subject: [PATCH 12/20] M7.REVIEW: use normal shared CI for the Proofing pane Remove the feature-only preview workflow from this feature PR. Retain all Rust implementation and regressions unchanged; document direct manual rendering and the exact-source historical previews. Credit: @totoz64 --- .github/workflows/spelling-review.yml | 71 --------------------------- docs/spelling-review.md | 19 ++++++- 2 files changed, 17 insertions(+), 73 deletions(-) delete mode 100644 .github/workflows/spelling-review.yml diff --git a/.github/workflows/spelling-review.yml b/.github/workflows/spelling-review.yml deleted file mode 100644 index 3adc7cdc8..000000000 --- a/.github/workflows/spelling-review.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Spelling review preview - -on: - pull_request: - paths: - - 'crates/ui-egui/src/proofing_pane.rs' - - 'crates/ui-egui/src/panes.rs' - - 'crates/ui-egui/src/lib.rs' - - 'crates/ui-egui/src/window_ui.rs' - - 'crates/ui-egui/src/control.rs' - - 'crates/ui-egui/src/documents.rs' - - 'crates/ui-egui/src/canvas.rs' - - 'crates/engine/src/cmd/review.rs' - - 'crates/engine/src/cmd/review/**' - - 'crates/engine/src/tests/**' - - 'crates/ui-egui/src/app.rs' - - 'crates/ui-egui/src/app/**' - - 'crates/ui-egui/src/canvas/**' - - 'crates/ui-egui/src/control/**' - - 'crates/ui-egui/src/panes/**' - - 'crates/ui-egui/src/dialogs/**' - - '.github/workflows/spelling-review.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: spelling-review-preview-${{ github.ref }} - cancel-in-progress: true - -jobs: - preview: - runs-on: ubuntu-24.04 - timeout-minutes: 45 - env: - CARGO_INCREMENTAL: '0' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Install build and software-rendering dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec - with: - components: rustfmt - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 - - name: Formatting - id: formatting - continue-on-error: true - run: cargo fmt --all -- --check - - name: Proofing navigation regression - run: cargo test --locked -p wordcraft-engine --lib spelling_review_dialog_navigation - - name: Correction form regressions - run: cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog -- --skip preview - - name: Render light and dark correction forms - run: cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog_preview -- --ignored --nocapture - - name: Save visual review artifacts - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: spelling-review-preview - path: crates/ui-egui/spelling-review-*.png - if-no-files-found: error - - name: Require formatting success - if: steps.formatting.outcome != 'success' - run: exit 1 diff --git a/docs/spelling-review.md b/docs/spelling-review.md index 383031a23..385f66018 100644 --- a/docs/spelling-review.md +++ b/docs/spelling-review.md @@ -1,6 +1,6 @@ # Spelling and grammar task pane -> **Last updated:** 2026-10-31 · **Status:** synchronized with main `e6a5cd0`; validation of this evening synchronization SHA is pending. The parent `7c20316` passed its exact-head hosted gate, three-platform Printing and proofing preview; those results do not validate this synchronization. Historical status: historical three-platform checks passed; at f1264f3 the revised hosted gate, previews, Windows and Linux passed, while macOS remained unvalidated after pre-run cancellations. Later review corrections required their own hosted checks. +> **Last updated:** 2026-11-01 · **Status:** the feature-specific preview workflow is removed to follow the shared-CI policy. Rust implementation and tests are unchanged from `3abe452`; validation of this documentation/CI-only follow-up SHA is pending. Historical parent `3abe452` passed its exact-head hosted gate, three-platform Printing and proofing preview; those results remain attributed to that parent. ## Word reference @@ -35,7 +35,21 @@ The language-independent pane is rebased by a three-way merge with main `436d444 - Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit point lookup at the focus, absent/unparseable-position selection fallback, unchanged selection, correction and undo. - UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching. - Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. -- Required gate: `cargo xtask ci`, plus the existing proofing preview workflow. Local attempt outcomes are recorded separately; an environment-limited local attempt does not replace hosted checks. No local compilation or test success is claimed for this synchronization. The coordinator must validate its new SHA; earlier hosted results validate their own SHAs only. +- Required gate: `cargo xtask ci`, through the repository's normal shared CI. The feature adds no workflow or shared-CI change. Local attempt outcomes are recorded separately; an environment-limited attempt does not replace hosted checks. Each follow-up SHA requires its own results. + +### Manual regression and visual review + +From the repository root, keep `Cargo.lock` and the existing dependency pins, use the normal native UI build prerequisites, and run: + +```sh +cargo test --locked -p wordcraft-engine --lib spelling_review_dialog_navigation +cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog -- --skip preview +cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog_preview -- --ignored --nocapture +``` + +The filters currently select two engine regressions, seven UI regressions and one explicitly ignored preview test. Check the reported counts; a successful command with zero selected tests is not verification. The last command needs a software or hardware graphics adapter supported by the headless renderer (for example Mesa Vulkan on Linux) and writes `crates/ui-egui/spelling-review-light.png` and `crates/ui-egui/spelling-review-dark.png`. Inspect both outputs and attach the resulting images manually to the review with their source SHA; the ignored test is not a new CI job. These synthetic English fixtures check pane geometry and actions, not an interactive Windows session or pixel-perfect Word identity. + +Historical images for `3abe452b7c26f624c09e0143dbee275776821dff` were generated by [run 41220215503](https://github.com/totoz64/wordcraft/actions/runs/41220215503), artifact `13049424116`, and inspected in both themes. They remain evidence for that exact parent SHA; removing the workflow does not relabel them as a render of this follow-up. The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. At `f1264f3`, the revised light/dark previews and full hosted gate passed; Windows and Linux UI checks passed, while macOS remained unvalidated after pre-run cancellations. Both PNGs were inspected, including the right-aligned Read Aloud control. The following review revision gives the exact selected spelling range priority over an overlapping grammar issue and requires new hosted checks. Exact pixel parity is not claimed. @@ -43,6 +57,7 @@ The first light/dark artifacts at `20205a5` were inspected: the sentence frame s | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Remove the feature-specific preview workflow to follow shared-CI policy; preserve all Rust and tests, document three direct commands and manually attached exact-SHA images; fresh follow-up validation pending | | 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `e6a5cd0` after the earlier `eecbb8f` synchronization; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; parent `7c20316` passed hosted checks, fresh evening-SHA validation pending, readiness unchanged | | 2026-10-30 | trivial | Merge the existing pane with main `3b7f7ed`; preserve explicit-position point lookup at the selection focus, implicit/parse-fallback selection priority and correction/undo; new follow-up SHA requires fresh hosted validation | | 2026-10-27 | trivial | Synchronize the existing pane with extracted engine/UI modules on main `436d444`; preserve selection/navigation/undo tests and compact menus, expand workflow path filters; new-SHA validation pending | From bf9e9fb8ae26e93b1780af82cda10216321dc422 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:17:19 +0100 Subject: [PATCH 13/20] DOCX: keep alternate-content table rows and cells (#5221) * Fix #5219: retain alternate-content table rows and cells * Re-wrap the collect() doc comment --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/docx/src/read/story.rs | 10 +++-- crates/docx/tests/it/fixtures.rs | 70 ++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++- docs/gaps.md | 5 ++- 5 files changed, 88 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fb6ef9ce0..af5687e6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-11-01 · **Change:** trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. + - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-01: Columns with Line between, made for selected text (a section in the middle of a page), draw their line; the line runs as tall as the section's text on the page, not the whole page (#5178). Readiness unchanged. - 2026-10-31: `.doc` import: text that names no font takes the stylesheet's standard font, so a Word 365 `.doc` in Aptos opens in Aptos (or its substitute) instead of Times New Roman, the font table's first entry (#5066). Readiness unchanged. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index ba62059dd..bce02dead 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -166,9 +166,9 @@ fn children_of_choice(ac: &El) -> Option<&El> { ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice")) } -/// Gather `name` children (rows, cells), looking through `w:sdt`/`w:customXml` wrappers; each -/// with the content controls that open before it and how many close after it. `pending` holds -/// controls opened but not yet given to an element. +/// Gather `name` children (rows, cells), looking through content controls, custom XML and the +/// selected alternate-content branch; each with the content controls that open before it and how +/// many close after it. `pending` holds controls opened but not yet given to an element. fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pending: &mut Vec, depth: usize) { if depth > 8 { return; @@ -194,6 +194,10 @@ fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pend } } else if k.name == "w:customXml" { collect(k, name, out, pending, depth + 1); + } else if k.name == "mc:AlternateContent" + && let Some(branch) = children_of_choice(k) + { + collect(branch, name, out, pending, depth + 1); } } } diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 559291f63..83a8d4cbf 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -48,6 +48,76 @@ fn read_body(body: &str) -> Document { wordcraft_docx::read(&docx(body, &[], &[])).unwrap() } +/// Markup compatibility replaces an AlternateContent wrapper with one selected branch, +/// including where a table expects rows or cells (ECMA-376 Part 3). +#[test] +fn table_alternate_content_keeps_selected_rows_and_cells() { + let cell = |text: &str| format!("{text}"); + let row = |text: &str| format!("{}", cell(text)); + let table = + |rows: &str, columns: usize| format!(r#"{}{rows}"#, r#""#.repeat(columns)); + let alternate = |chosen: &str, fallback: &str, requires: &str| { + format!( + r#"{chosen}{fallback}"# + ) + }; + for (requires, use_choice) in [("wps", true), ("alias", true), ("unknown", false), ("wps unknown", false)] { + for cells in [false, true] { + let choice = if cells { cell("Chosen cell") } else { row("Chosen row") }; + let fallback = if cells { cell("Fallback cell") } else { row("Fallback row") }; + let selected = if use_choice { &choice } else { &fallback }; + let middle = alternate(&choice, &fallback, requires); + let (actual, expected) = if cells { + ( + format!("{}{middle}{}", cell("Before"), cell("After")), + format!("{}{selected}{}", cell("Before"), cell("After")), + ) + } else { + (format!("{}{middle}{}", row("Before"), row("After")), format!("{}{selected}{}", row("Before"), row("After"))) + }; + let columns = if cells { 3 } else { 1 }; + let expected = read_body(&table(&expected, columns)); + let wrapped = format!( + r#"{}"#, + table(&actual, columns) + ); + let mut actual = read_body(&wrapped); + for round in 0..=2 { + assert_eq!(actual.body, expected.body, "requires={requires}, cells={cells}, round={round}"); + if round < 2 { + actual = wordcraft_docx::read(&wordcraft_docx::write(&actual).unwrap()).unwrap(); + } + } + } + } +} + +#[test] +fn table_alternate_content_keeps_nested_row_and_cell_controls() { + let wrap = + |tag: &str, content: &str| format!(r#"{content}"#); + let alternate = + |content: &str| format!(r#"{content}"#); + let cell = "Controlled cell"; + let oracle_cell = wrap("cell", cell); + let actual_cell = alternate(&wrap("cell", &alternate(cell))); + let oracle_row = wrap("row", &format!("{oracle_cell}")); + let actual_row = wrap("row", &alternate(&format!("{actual_cell}"))); + let expected = read_body(&format!("{oracle_row}")); + let mut actual = read_body(&format!("{}", alternate(&actual_row))); + for round in 0..=2 { + assert_eq!(actual.body, expected.body, "round={round}"); + let table = actual.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(table.rows[0].controls.open[0].tag, "row"); + assert_eq!(table.rows[0].controls.close, 1); + assert_eq!(table.rows[0].cells[0].controls.open[0].tag, "cell"); + assert_eq!(table.rows[0].cells[0].controls.close, 1); + if round < 2 { + actual = wordcraft_docx::read(&wordcraft_docx::write(&actual).unwrap()).unwrap(); + } + } +} + #[test] fn repeated_style_ids_use_last() { let styles = format!( diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63fa04f58..fd2c19880 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-11-01 · **Change:** trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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; @@ -269,6 +269,8 @@ Repeated DOCX style IDs use the last complete definition. The original regressio Custom document properties retain their bookmark link targets through DOCX saves, native JSON reopens, value edits and dialog renames (#4767). `file.properties` accepts a text string or a linked text object with `value` and optional `linkTarget`. Strings keep the existing association. Objects replace it, with an omitted or null target meaning unlinked. Values and bookmark targets are limited to 1 MiB each. Linked values are not evaluated; the dialog layout and field-update behavior are unchanged. Original fixtures cover ordinary and raw property values; broader corpus qualification and readiness estimates remain unchanged. +Table rows and cells inside `mc:AlternateContent` use the same selected-branch policy as paragraphs and runs (#5219), rather than disappearing on open and save. Original synthetic fixtures cover Choice/Fallback selection, namespace aliases, mixed requirements, row/cell order and nested content controls through two DOCX saves. This fixes a focused preservation case; native Word and broader real-world corpus qualification remain open. + ## Revision history > 2026-10-28 · Charts and SmartArt: text the font lacks (Chinese axis labels and legends) is drawn in a fallback face instead of boxes, one face per run as in body text (#4720, `layout::tests::chart_text_the_font_lacks_is_drawn_in_a_fallback_face`). @@ -279,6 +281,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle | | 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) | | 2026-10-31 | trivial | DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word (#5077) | diff --git a/docs/gaps.md b/docs/gaps.md index 694aa5923..735c988ab 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -142,6 +142,8 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): Recipient label generation (#4880) now includes all selected recipients across as many sheets as needed, retaining filtered/sorted order and the chosen product geometry. This fixes the one-sheet omission; Update Labels and Finish & Merge template behavior remain separate, and gap 21 remains open. Readiness estimates are unchanged. +Table rows and cells inside `mc:AlternateContent` use the same selected-branch policy as paragraphs and runs (#5219), rather than disappearing on open and save. Original synthetic fixtures cover Choice/Fallback selection, namespace aliases, mixed requirements, row/cell order and nested content controls through two DOCX saves. This fixes a focused preservation case; native Word and broader real-world corpus qualification remain open. + ## Revision history > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. @@ -152,6 +154,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | | 2026-11-01 | trivial | A middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed; readiness estimates unchanged | From 3e2c79407d9546474afd84619099f06849aa1ece Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:24:32 +0100 Subject: [PATCH 14/20] review.compare: keep text boxes of both documents in the result (#5248) Fixes #5235 Co-authored-by: Claude Opus 5.5 --- crates/doc/src/edit.rs | 24 ++++- crates/engine/src/cmd/tools/compare.rs | 124 +++++++++++++++++++++---- crates/engine/src/cmd/tools/tests.rs | 80 ++++++++++++++++ 3 files changed, 207 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 66d483e81..17ea5dff4 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -205,11 +205,15 @@ struct BoxBudget { costs: BTreeMap, /// Boxes left without a story because the paste's budget couldn't hold theirs. skipped: usize, + /// A box the fragment has no story for may show this document's story of that id (a + /// fragment copied from this document without its parts). Off for another document's + /// fragment, whose ids mean nothing here. + local: bool, } impl BoxBudget { fn new() -> BoxBudget { - BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 } + BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0, local: true } } } @@ -971,7 +975,7 @@ impl Document { budget.skipped = budget.skipped.saturating_add(1); return None; } - let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; + let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| budget.local && p.kind == PartKind::TextBox))?; // Charge what the copy really allocates before making any of it: the block slots, and the // blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many // boxes show them, so they cost nothing more. Worked out once per paste. @@ -1012,6 +1016,22 @@ impl Document { Some(id) } + /// The blocks of `frag`, a fragment of another document, with every text box given a new + /// story of its own here copied from the fragment's parts (as a paste does), to place them + /// in this document; a box the fragment has no story for keeps none. + pub fn adopt_fragment(&mut self, frag: &Fragment) -> Vec { + let mut blocks = frag.blocks.clone(); + let mut budget = BoxBudget { local: false, ..BoxBudget::new() }; + self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); + if budget.skipped > 0 { + log::warn!( + "{} text boxes are left empty; their stories are past what one copy brings ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)", + budget.skipped + ); + } + blocks + } + /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { // Tables that would nest deeper than the limit here become their paragraphs (#1596). diff --git a/crates/engine/src/cmd/tools/compare.rs b/crates/engine/src/cmd/tools/compare.rs index d7f1f8d28..d275d7365 100644 --- a/crates/engine/src/cmd/tools/compare.rs +++ b/crates/engine/src/cmd/tools/compare.rs @@ -1,6 +1,8 @@ use super::{ - Arc, Block, CharProps, CmdError, CmdResult, Document, Paragraph, RevisionKind, Selection, Session, StoryRef, Value, json, p, para_block, + Arc, Block, CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, RevisionKind, Selection, Session, StoryRef, Value, json, p, + para_block, }; +use wordcraft_doc::para::OBJ; /// Compare and Combine take the same settings. pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#; @@ -64,7 +66,13 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone()); let date = crate::cmd::now_iso(); let old: Vec> = s.doc.body.clone(); - let new: Vec> = revised.body.clone(); + let new: Vec> = if revised.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::TextBox) { + // The revised document's text boxes get stories of their own in the result, so its + // boxes never show a story of the original's (or none at all). + s.doc.adopt_fragment(&revised.body_fragment()).into_iter().map(Arc::new).collect() + } else { + revised.body.clone() + }; s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None }); let ins = (s.doc.revisions.len() - 1) as u32; s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author: author.clone(), date: date.clone(), move_name: None }); @@ -86,6 +94,16 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { let (a, b) = (old.get(i), new.get(j)); // The revised block, with its formatting changes tracked. if let (Some(Block::Para(pa)), Some(Block::Para(pb))) = (a.map(|x| &**x), b.map(|x| &**x)) { + if !same_objects(&s.doc, pa, pb) { + // Same text, but objects (text boxes, pictures…) differ: diffed, so the + // original's objects aren't lost. + out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))); + changes += 1; + i += 1; + j += 1; + k += 1; + continue; + } let mut p = pb.clone(); if opts.formatting && pa.text == pb.text { let before = s.doc.revisions.len(); @@ -111,7 +129,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { for x in 0..dels.max(inss) { let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None }); match (a.map(|x| &**x), b.map(|x| &**x)) { - (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))), + (Some(Block::Para(pa)), Some(Block::Para(pb))) => { + out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))) + } _ => { if let Some(x) = a { out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0))); @@ -308,30 +328,97 @@ fn flatten_region(out: &mut Vec, region: &[(Op, usize, Kind)]) { out.extend(std::iter::repeat_n(Op::Ins, inss)); } -/// Words with the spaces after them ("The ", "cat "), or single characters. -fn tokens(s: &str, characters: bool) -> Vec { - if characters { - return s.chars().map(String::from).collect(); +/// A token of a changed paragraph: a word (or character), or an inline object without text +/// of its own (a text box anchor, a picture, a bookmark…), which is compared and kept whole. +enum Tok<'a> { + Text(String), + Obj(&'a InlineObject), +} + +/// What a comparison sees of a token: the text as the options say, an object with the text of +/// the text boxes it shows instead of their story ids (each document numbers its own stories). +#[derive(PartialEq)] +enum Key { + Text(String), + Obj(Box, Vec), +} + +fn obj_key(doc: &Document, o: &InlineObject) -> Key { + let texts = o.text_boxes().into_iter().map(|id| doc.plain_text(StoryRef::Part(id))).collect(); + let mut o = o.clone(); + for story in o.text_box_slots() { + *story = None; + } + Key::Obj(Box::new(o), texts) +} + +/// Whether two paragraphs hold the same objects, as compared. +fn same_objects(doc: &Document, a: &Paragraph, b: &Paragraph) -> bool { + a.objects.len() == b.objects.len() && a.objects.iter().zip(&b.objects).all(|(x, y)| x == y || obj_key(doc, x) == obj_key(doc, y)) +} + +/// Words with the spaces after them ("The ", "cat "), or single characters, and the objects +/// between them. Objects with text (fields, equations…) count as their text. +fn tokens(p: &Paragraph, characters: bool) -> Vec> { + fn push(v: &mut Vec>, cur: &mut String, c: char, characters: bool) { + cur.push(c); + if characters || c == ' ' { + v.push(Tok::Text(std::mem::take(cur))); + } } let mut v = Vec::new(); let mut cur = String::new(); - for c in s.chars() { - cur.push(c); - if c == ' ' { - v.push(std::mem::take(&mut cur)); + let mut objects = p.objects.iter(); + for c in p.text.chars() { + if c != OBJ { + push(&mut v, &mut cur, c, characters); + continue; + } + match objects.next() { + Some(o) if o.plain_text().is_empty() => { + if !cur.is_empty() { + v.push(Tok::Text(std::mem::take(&mut cur))); + } + v.push(Tok::Obj(o)); + } + Some(o) => o.plain_text().chars().for_each(|c| push(&mut v, &mut cur, c, characters)), + None => { + // A marker without its object (a broken paragraph): kept as a visible + // placeholder, so the loss shows in the result instead of vanishing. + log::warn!("compare: an object marker without its object"); + push(&mut v, &mut cur, char::REPLACEMENT_CHARACTER, characters); + } } } if !cur.is_empty() { - v.push(cur); + v.push(Tok::Text(cur)); } v } -fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { +/// Add a token at the end of `p` with `props`. +fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) { + let at = p.len(); + match t { + Some(Tok::Text(t)) => { + let _ = p.insert_text(at, t, props); + } + Some(Tok::Obj(o)) => { + let _ = p.insert_object(at, (*o).clone(), props); + } + None => {} + } +} + +fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { let (at, bt) = (a.plain_text(), b.plain_text()); let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF; - let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars)); - let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>(), budget); + let (ta, tb) = (tokens(a, chars), tokens(b, chars)); + let key = |t: &Tok<'_>| match t { + Tok::Text(t) => Key::Text(opts.key(t)), + Tok::Obj(o) => obj_key(doc, o), + }; + let ops = lcs_ops(&ta.iter().map(key).collect::>(), &tb.iter().map(key).collect::>(), budget); // Only at character granularity: a matched whole word almost never happens by coincidence, // but a matched single character very often does (digits, punctuation), so only there does // a short interior match need folding back into its surrounding edit. @@ -341,20 +428,19 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOpt let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default(); let (mut i, mut j) = (0, 0); for op in ops { - let at = p.len(); match op { Op::Same => { // Equal as compared (perhaps up to case or spacing): the revised text. - let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &base); + put(&mut p, tb.get(j), &base); i += 1; j += 1; } Op::Del => { - let _ = p.insert_text(at, ta.get(i).map(String::as_str).unwrap_or(""), &CharProps { del: Some(del), ..base.clone() }); + put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() }); i += 1; } Op::Ins => { - let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &CharProps { ins: Some(ins), ..base.clone() }); + put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() }); j += 1; } } diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 5ac4df958..4683817e6 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -80,6 +80,86 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() { ); } +#[test] +fn compare_keeps_text_boxes_of_the_original() { + // Text boxes are anchored in body paragraphs: one after changed text, one alone in an + // otherwise empty paragraph, one after text the revised document keeps. Their anchors must + // survive the compare (marked deleted, since the revised text has no boxes), so no text is lost. + for show_in in ["original", "revised", "new"] { + let mut s = Session::new(Document::from_text("Body one.\n\nKeep.")); + let mut boxes = Vec::new(); + for (para, off, text) in [(0usize, "Body one.".len(), "Alpha boxword gamma."), (1, 0, "Vml onlyword here."), (2, "Keep.".len(), "Kept box.")] + { + s.run("caret.set", &json!({"pos": Pos::body(para, off)})).unwrap(); + let id = s.run("insert.textBox", &json!({"text": text})).unwrap()["story"].as_u64().unwrap() as u32; + boxes.push((id, text)); + } + s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap(); + s.run("review.compare", &json!({"text": "Totally new first.\nSecond new line.\nKeep.", "showIn": show_in})).unwrap(); + for (id, text) in &boxes { + let anchor = s.doc.text_box_anchor(*id).unwrap_or_else(|| panic!("{show_in}: the box \"{text}\" lost its anchor")); + assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text); + let p = s.doc.para(anchor.story, &anchor.path).unwrap(); + assert!( + p.props_of_char(anchor.off - wordcraft_doc::para::OBJ.len_utf8()).del.is_some(), + "{show_in}: the box \"{text}\" is not marked deleted" + ); + } + s.run("review.rejectAll", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Body one.\n\nKeep."); + for (id, text) in &boxes { + assert!(s.doc.text_box_anchor(*id).is_some(), "{show_in}: rejecting lost the box \"{text}\""); + assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text); + } + } +} + +#[test] +fn compare_with_a_revised_file_keeps_its_text_boxes_backed() { + // Text boxes in both documents, the revised one from a file: every anchor in the result + // shows a text box story of the result, and both documents' boxes are there, marked. + fn with_boxes(text: &str, boxes: &[(usize, usize, &str)]) -> Session { + let mut s = Session::new(Document::from_text(text)); + for (para, off, text) in boxes { + s.run("caret.set", &json!({"pos": Pos::body(*para, *off)})).unwrap(); + s.run("insert.textBox", &json!({"text": text})).unwrap(); + } + s + } + /// Each text box anchored in the body: its story's text, and whether its anchor is deleted + /// or inserted. + fn boxes(d: &Document) -> Vec<(String, bool, bool)> { + let mut out = Vec::new(); + for path in d.para_paths(StoryRef::Body) { + let p = d.para(StoryRef::Body, &path).unwrap(); + for (off, o) in p.objects_with_offsets() { + for id in o.text_boxes() { + let part = d.parts.get(&id).unwrap_or_else(|| panic!("an anchor shows story {id}, which the result lacks")); + assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox, "story {id}"); + let c = p.props_of_char(off); + out.push((d.plain_text(StoryRef::Part(id)), c.del.is_some(), c.ins.is_some())); + } + } + } + out.sort(); + out + } + let rev = with_boxes("Body one.\nNew middle.\nKeep.", &[(1, "New middle.".len(), "Revised box."), (2, "Keep.".len(), "Kept box.")]); + let path = std::env::temp_dir().join(format!("wc-compare-boxes-{}.docx", std::process::id())); + crate::io::save_path(&path, &rev.doc).unwrap(); + let t = |t: &str, del, ins| (t.to_string(), del, ins); + for show_in in ["original", "revised", "new"] { + for (then, left) in [("review.acceptAll", "Revised box."), ("review.rejectAll", "Original box.")] { + let mut s = with_boxes("Body one.\nOld middle.\nKeep.", &[(1, "Old middle.".len(), "Original box."), (2, "Keep.".len(), "Kept box.")]); + s.run("review.compare", &json!({"path": path.to_string_lossy(), "showIn": show_in})).unwrap(); + assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t("Original box.", true, false), t("Revised box.", false, true)], "{show_in}"); + s.run(then, &json!({})).unwrap(); + assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t(left, false, false)], "{show_in} {then}"); + } + } + let _ = std::fs::remove_file(&path); +} + #[test] fn compare_bounds_total_diff_work() { // Many long, slightly different paragraphs: each pair alone fits one comparison table, From 89c0d516ae072a8501ec9091f0ccdde41f5bbf04 Mon Sep 17 00:00:00 2001 From: Naxvog <4963183+naxvog@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:13 +0200 Subject: [PATCH 15/20] Files: documents keep their embedded alternative format chunks, and MHT opens Word brings a document's alternative format chunks (w:altChunk) in where they stand when it opens the file; WordCraft left them out, so a document made of one (as generators and mail-merge tools write them) opened empty. - docx: read_with/ReadOptions with a ChunkImporter for formats other than WordprocessingML; nested Word documents are read here (at most 4 deep, 256 chunks and 512 MB of chunk bytes a file, XML depth counted on). - doc: Document::adopt (moved from paste) brings another document's media, lists, styles, notes, comments and revisions along under new ids; pictures in groups and charts are relinked. Insert > Text from File uses it and now keeps the file's pictures and lists. - engine: chunks in MHT, HTML (in their charset), RTF and plain text go through the format importers. - formats: MHT web archives open (MIME, quoted-printable/base64, charsets, pictures by Content-Location or cid). HTML reads style element rules (simple selectors, cascade, budgets), paragraph margins, indents, line height, cell widths and borders; an opened page takes Word's web-page defaults (Times New Roman 12 pt, single lines, black headings, automatic spacing). html::decode knows the single-byte Windows code pages. docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + apps/wordcraft-web/src/web.rs | 3 +- crates/doc/src/edit.rs | 207 ++++++++++++ crates/doc/src/edit/tests.rs | 164 +++++++++ crates/docx/src/flat.rs | 2 +- crates/docx/src/lib.rs | 15 +- crates/docx/src/package.rs | 8 +- crates/docx/src/read.rs | 37 ++- crates/docx/src/read/chunk.rs | 183 ++++++++++ crates/docx/src/read/meta.rs | 32 +- crates/docx/src/read/parts.rs | 2 +- crates/docx/src/read/story/blocks.rs | 27 +- crates/docx/src/read/story/drawing.rs | 6 +- crates/docx/src/wordml2003.rs | 2 +- crates/docx/tests/it/alt_chunks.rs | 277 ++++++++++++++++ crates/docx/tests/it/main.rs | 1 + crates/engine/src/cmd/insert.rs | 3 +- crates/engine/src/cmd/paste.rs | 107 +----- crates/engine/src/io.rs | 51 ++- crates/engine/src/io_ext.rs | 2 +- crates/engine/src/tests.rs | 137 ++++++++ crates/formats/src/html.rs | 14 +- crates/formats/src/html/builder.rs | 460 +++++++++++++++++++++++--- crates/formats/src/html/css.rs | 17 +- crates/formats/src/html/entity.rs | 38 ++- crates/formats/src/html/import.rs | 38 ++- crates/formats/src/html/lexer.rs | 10 +- crates/formats/src/html/tests.rs | 192 +++++++++++ crates/formats/src/lib.rs | 14 +- crates/formats/src/mht.rs | 444 +++++++++++++++++++++++++ crates/formats/src/model/from_doc.rs | 3 + crates/formats/src/model/to_doc.rs | 9 + crates/formats/src/model/types.rs | 26 +- crates/formats/src/rtf/styles.rs | 10 +- crates/ui-egui/src/file_dialogs.rs | 3 +- docs/file-format-parity.md | 12 +- docs/gaps.md | 5 +- 37 files changed, 2334 insertions(+), 229 deletions(-) create mode 100644 crates/docx/src/read/chunk.rs create mode 100644 crates/docx/tests/it/alt_chunks.rs create mode 100644 crates/formats/src/mht.rs diff --git a/ROADMAP.md b/ROADMAP.md index af5687e6d..a72ad6ca1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -355,6 +355,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | @@ -691,6 +692,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | | 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | | 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 7fa947f02..580e28e6c 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -9,7 +9,8 @@ use wordcraft_engine::Session; use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; -const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; +const DOC_EXTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS; mod font_store; diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 17ea5dff4..c485239e7 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -389,7 +389,107 @@ fn restyle_block(b: &mut Block, from: &str, to: &Option, depth: usize) - } } +/// One past the highest permission range id in `blocks`. +fn perm_end<'a>(blocks: impl Iterator>) -> u32 { + let mut end = 0u32; + for b in blocks { + each_para(b, 0, &mut |p| { + for o in &p.objects { + if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o { + end = end.max(id.saturating_add(1)); + } + } + }); + } + end +} + /// Text box story ids of a paragraph's shapes. +/// What [`Document::adopt`] renumbers in another document's content: media keys, lists, note +/// and comment stories, comment ids, text boxes (one it doesn't bring is dropped) and revisions +/// (after ours, at `rev`). +struct Relink<'a> { + media: &'a BTreeMap, + nums: &'a BTreeMap, + notes: &'a BTreeMap, + comments: &'a BTreeMap, + boxes: &'a BTreeMap, + /// Added to permission range ids: ours come first. + perms: u32, + rev: u32, +} + +impl Relink<'_> { + // shortcut: tracked changes of table, row and cell properties keep their revision ids; renumber them too when tables from other documents carry revisions. + fn block(&self, b: &mut Block) { + each_para_mut(b, 0, &mut |p| self.para(p)); + } + + fn para(&self, p: &mut Paragraph) { + // A tracked change's old formatting names lists as the live one does. + let old = p.props.fmt_change.as_mut().and_then(|f| f.old.numbering.as_mut()); + for n in p.props.numbering.iter_mut().chain(old) { + if n.num != 0 { + n.num = self.nums.get(&n.num).copied().unwrap_or(0); + } + } + let shift = |r: &mut u32| *r = r.saturating_add(self.rev); + for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) { + c.ins.iter_mut().chain(c.del.iter_mut()).for_each(shift); + if let Some(f) = c.fmt_change.as_mut() { + shift(&mut f.rev); + } + } + if let Some(f) = p.props.fmt_change.as_mut() { + shift(&mut f.rev); + } + if let Some(f) = p.section.as_deref_mut().and_then(|s| s.fmt_change.as_mut()) { + shift(&mut f.rev); + } + if let Some(f) = p.props.num_change.as_mut() { + shift(&mut f.rev); + } + for o in &mut p.objects { + self.object(o, 0); + } + p.touch(); + } + + fn object(&self, o: &mut InlineObject, depth: usize) { + // An id that names nothing here becomes one that names nothing at all. + let map = |m: &BTreeMap, id: &mut u32| *id = m.get(id).copied().unwrap_or(u32::MAX); + match o { + InlineObject::Image { media, .. } => { + if let Some(k) = self.media.get(media.as_str()) { + *media = k.clone(); + } + } + InlineObject::Graphic { graphic, .. } => { + let mapped = |m: &str| self.media.get(m).filter(|k| k.as_str() != m); + if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) { + for i in &mut Arc::make_mut(graphic).items { + if let crate::graphic::GraphicItem::Image { media, .. } = i + && let Some(k) = self.media.get(media.as_str()) + { + *media = k.clone(); + } + } + } + } + InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => children.iter_mut().for_each(|c| self.object(&mut c.obj, depth + 1)), + InlineObject::NoteRef { id, .. } => map(self.notes, id), + InlineObject::CommentStart { id } | InlineObject::CommentEnd { id } => map(self.comments, id), + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => *id = id.saturating_add(self.perms), + _ => {} + } + for story in o.text_box_slots() { + if story.is_some_and(|id| !self.boxes.contains_key(&id)) { + *story = None; + } + } + } +} + fn box_ids(p: &Paragraph) -> impl Iterator + '_ { p.objects.iter().flat_map(InlineObject::text_boxes) } @@ -866,6 +966,113 @@ impl Document { Fragment { blocks, parts } } + /// Another document's body as a fragment of this one, to insert: the media, lists, styles, + /// notes, comments and tracked changes it refers to come along (lists, notes, comments and + /// revisions renumbered so they can't collide with ours; a style we already have by id stays + /// ours), and its text boxes ride in [`Fragment::parts`]. + pub fn adopt(&mut self, src: Document) -> Fragment { + self.adopt_in(src, &mut 0) + } + + /// [`Self::adopt`], its permission range ids from at least `perms` on (and past ours); + /// `perms` moves past them. + fn adopt_in(&mut self, src: Document, perms: &mut u32) -> Fragment { + let mut media = BTreeMap::new(); + for (key, data) in &src.media { + let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); + media.insert(key.clone(), self.add_media(data.to_vec(), ext)); + } + let next_abs = self.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); + let mut abs_map = BTreeMap::new(); + for (i, a) in src.numbering.abstracts.iter().enumerate() { + let id = next_abs.saturating_add(i as u32); + abs_map.insert(a.id, id); + self.numbering.abstracts.push(crate::numbering::AbstractNum { id, ..a.clone() }); + } + let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); + let mut nums = BTreeMap::new(); + for (i, n) in src.numbering.nums.iter().enumerate() { + let id = next_num.saturating_add(i as u32); + nums.insert(n.id, id); + let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); + self.numbering.nums.push(crate::numbering::Num { id, abstract_id, ..n.clone() }); + } + let renumber = |n: &mut Option| { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = nums.get(&n.num).copied().unwrap_or(0); + } + }; + for st in &src.styles.styles { + if self.styles.get(&st.id).is_none() { + let mut st = st.clone(); + // Our defaults stay ours. + st.default = false; + renumber(&mut st.para.numbering); + self.styles.upsert(st); + } + } + let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX); + self.revisions.extend(src.revisions.iter().cloned()); + // Notes and comments get stories (and comments ids) of their own here; their text is + // relinked below, once every new id is known. + let mut notes = BTreeMap::new(); + for (id, part) in src.parts.iter().filter(|(_, p)| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) { + notes.insert(*id, self.add_part(part.kind, Vec::new())); + } + let mut comments = BTreeMap::new(); + let mut next_comment = self.comments.keys().next_back().map_or(0, |k| k.saturating_add(1)); + for (id, c) in &src.comments { + if src.parts.get(&c.part).is_some_and(|p| p.kind == PartKind::Comment) { + notes.insert(c.part, self.add_part(PartKind::Comment, Vec::new())); + comments.insert(*id, next_comment); + next_comment = next_comment.saturating_add(1); + } + } + let boxes: BTreeMap = src.parts.iter().filter(|(_, p)| p.kind == PartKind::TextBox).map(|(k, p)| (*k, p.clone())).collect(); + // Its permission ranges are numbered after ours, so no end of theirs closes one of ours. + let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms); + *perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter())))); + let link = Relink { media: &media, nums: &nums, notes: ¬es, comments: &comments, boxes: &boxes, perms: first_perm, rev }; + let mut parts = boxes.clone(); + for part in parts.values_mut() { + part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b))); + } + // A note's or comment's text boxes get stories here now; the body's go with the fragment. + let mut budget = BoxBudget::new(); + for (old, new) in ¬es { + let Some(part) = src.parts.get(old) else { continue }; + let mut blocks: Vec = part.blocks.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + if let Some(p) = self.parts.get_mut(new) { + p.blocks = blocks.into_iter().map(Arc::new).collect(); + } + } + for (id, c) in &src.comments { + if let (Some(new), Some(part)) = (comments.get(id), notes.get(&c.part)) { + let parent = c.parent.and_then(|p| comments.get(&p).copied()); + // Its durable id is unique in its own document only: the writer gives the copy one. + self.comments.insert(*new, crate::Comment { parent, part: *part, durable_id: None, ..c.clone() }); + } + } + let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + Fragment { blocks, parts } + } + + /// [`Self::adopt`] as blocks to place in a story directly (a reader building one): its text + /// boxes already have stories of their own here. `perms` is the first permission range id the + /// reader hasn't given out (ranges it holds aren't in this document yet); it moves past the + /// adopted ones. + pub fn adopt_blocks(&mut self, src: Document, perms: &mut u32) -> Vec { + let Fragment { mut blocks, parts } = self.adopt_in(src, perms); + let mut budget = BoxBudget::new(); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + blocks + } + /// Copies of the text box stories shapes in `blocks` show, nested boxes included. fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap { let mut out = BTreeMap::new(); diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index 68908775b..34cf563ee 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -664,3 +664,167 @@ fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap(); assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed"); } +#[test] +fn adopting_a_document_renumbers_what_it_refers_to() { + use crate::para::NoteKind; + use crate::{Comment, Revision, RevisionKind}; + // Ours already has a footnote, a comment, a list and a revision with the ids the other's use. + let mut ours = doc(&["ours"]); + let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]); + ours.comments.insert(0, Comment { part: our_note, ..Comment::default() }); + let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]); + let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]); + theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() }); + let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() }; + style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + theirs.styles.upsert(style); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + p.runs[0].props.del = Some(0); + let props = p.props_of_char(0).clone(); + p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap(); + p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let new_list = p.props.numbering.unwrap().num; + assert_ne!(new_list, our_list); + assert!(ours.numbering.num(new_list).is_some()); + assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list)); + // Their deletion is their revision, after ours. + let del = p.runs.iter().find_map(|r| r.props.del).unwrap(); + assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete)); + // Their note and comment come along under ids of their own. + let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o { + InlineObject::NoteRef { id, .. } => (Some(*id), c), + InlineObject::CommentStart { id } => (n, Some(*id)), + _ => (n, c), + }); + let text = |id: u32| { + ours.parts + .get(&id) + .map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::()) + }; + assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note")); + let comment = ours.comments.get(&comment_id.unwrap()).unwrap(); + assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment"))); + // Its durable id is the other document's: the writer gives the copy one of its own. + assert_eq!(comment.durable_id, None); + assert_eq!(text(our_note).as_deref(), Some("our note")); +} + +#[test] +fn an_adopted_section_breaks_tracked_change_names_its_revision() { + use crate::{Revision, RevisionKind, SectionProps}; + let mut ours = doc(&["ours"]); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() })); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev); + assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format)); +} + +#[test] +fn an_adopted_tracked_format_change_keeps_its_old_list() { + use crate::props::{NumRef, ParaProps, PropChange}; + use crate::{Revision, RevisionKind}; + let mut ours = doc(&["ours"]); + let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.numbering = Some(NumRef { num: list, level: 0 }); + p.props.fmt_change = PropChange::boxed(0, ParaProps { numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() }); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let old = &p.props.fmt_change.as_ref().unwrap().old; + assert_eq!(old.numbering, p.props.numbering); + assert_ne!(old.numbering.map(|n| n.num), Some(our_list)); +} + +#[test] +fn adopted_styles_never_become_our_defaults() { + use crate::styles::{Style, StyleKind}; + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + // A localized default character style, and one whose id we have too. + theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + ours.adopt(theirs); + assert!(ours.styles.styles.iter().all(|s| !s.default)); +} + +#[test] +fn adopted_permission_ranges_keep_ids_of_their_own() { + let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() }; + let mut ours = doc(&["ours"]); + ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap(); + // Theirs: a closed range and one left open, numbered from 0 like ours. + let mut theirs = doc(&["theirs"]); + theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let ids: Vec = p + .objects + .iter() + .filter_map(|o| match o { + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id), + _ => None, + }) + .collect(); + assert_eq!(ids.len(), 3); + assert!(!ids.contains(&0), "{ids:?}"); + assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}"); + assert_ne!(ids[1], ids[2]); +} + +#[test] +fn an_adopted_notes_text_box_gets_a_story_of_its_own() { + use crate::para::NoteKind; + // Ours has a text box under the id their note's text box has. + let mut ours = doc(&["ours"]); + ours.add_part(PartKind::Footnote, Vec::new()); + let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box"); + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]); + let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]); + assert_eq!(their_box, our_box); + theirs + .insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default()) + .unwrap(); + theirs + .insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default()) + .unwrap(); + + let blocks = ours.adopt(theirs).blocks; + let Some(Block::Para(p)) = blocks.first() else { panic!() }; + let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap(); + let story = ours.parts[&new_note] + .blocks + .iter() + .find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None }) + .unwrap(); + assert_ne!(story, our_box); + let part = &ours.parts[&story]; + assert_eq!(part.kind, PartKind::TextBox); + assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box")); +} diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index a11b69f15..0e2b1dd77 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind { /// Read a Flat OPC file. pub fn read(bytes: &[u8]) -> Result { - crate::read::read_opened(&unpack(bytes)?) + crate::read::read_opened(&unpack(bytes)?, None) } /// The data element of the part being read. diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 72cfdebfa..e964b0d14 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -32,7 +32,7 @@ pub use crypt::{ }; pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use fonts::{EmbeddedFont, embedded_fonts}; -pub use read::read; +pub use read::{AltChunk, ChunkImporter, ReadOptions, read}; pub use smart_art::smart_art_items; pub use wordml2003_write::write as write_wordml2003; pub use write::{write, write_as}; @@ -84,11 +84,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm"; /// `password` first. An encrypted package without a password gives /// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`]. pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result { + read_with(bytes, &ReadOptions { password, ..Default::default() }) +} + +/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and +/// an importer for `w:altChunk` content in formats other than WordprocessingML (see +/// [`ChunkImporter`]). +pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result { if is_encrypted(bytes) { - let package = zeroize::Zeroizing::new(decrypt(bytes, password)?); - return read::read_package(&package); + let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?); + return read::read_package(&package, opts.chunks); } - read(bytes) + read::read_package(bytes, opts.chunks) } /// Which kind of WordprocessingML package to write. The main part's content type differs per diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index bdefc38fa..0f91ef8a8 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -47,6 +47,7 @@ pub mod rt { pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors"; pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument"; + pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk"; } /// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8). @@ -252,6 +253,11 @@ impl Package { Ok(Package::new(files)) } + /// The bytes of all its parts. + pub fn size(&self) -> u64 { + self.files.values().map(|f| f.len() as u64).sum() + } + /// A package from parts already in memory (a Flat OPC file, a converted document), by part /// name without the leading `/`. pub fn from_parts(files: BTreeMap>) -> Package { @@ -587,7 +593,7 @@ mod tests { ("word/media/pic.png", b"\x89PNG picture bytes".to_vec()), ]); let pkg = Package::open(&z).unwrap(); - let doc = crate::read::read_opened(&pkg).unwrap(); + let doc = crate::read::read_opened(&pkg, None).unwrap(); let held = doc.media.values().next().expect("the picture is read"); assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy"); assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged"); diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 8f77ec63c..4c6b1cb01 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -1,6 +1,7 @@ //! DOCX → [`Document`]. pub(crate) mod chart; +mod chunk; pub(crate) mod diagram; mod drawing_color; mod embed; @@ -13,7 +14,7 @@ mod shape_style; mod story; mod wordart; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events}; @@ -46,6 +47,9 @@ pub(crate) use meta::PROTECTION_HASH_ATTRS; pub(crate) use styles::LOWER_NAMES; use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links}; +use chunk::ChunkState; +pub use chunk::{AltChunk, ChunkImporter, ReadOptions}; + /// Most fonts read from a font table, and the longest name (hostile files). const MAX_TABLE_FONTS: usize = 2000; const MAX_FONT_NAME: usize = 256; @@ -87,12 +91,12 @@ pub(crate) struct Reader<'p> { pub bookmarks: HashMap, /// Restrict Editing exception ranges (`w:permStart`): the file's id → ours. pub perms: HashMap, + /// The next permission range id to give out (chunks' ranges take theirs from it too). + pub next_perm: u32, /// Chart and diagram parts by path, parsed once. graphic_parts: HashMap>>, /// Charts and diagrams by (kind, part path, width bits, height bits), built once. graphics: HashMap<(GraphicKind, String, u32, u32), Arc>, - /// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]). - graphic_budget: usize, /// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id /// was kept while reading (those not glued to are dropped at the end). pub glued: HashSet, @@ -103,6 +107,8 @@ pub(crate) struct Reader<'p> { content_types: Option, /// Parts kept for charts, diagrams and OLE objects (see `embed`). embedded: EmbeddedManifest, + /// What alternative format chunks may still bring in, and how (see [`chunk`]). + chunks: ChunkState<'p>, /// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box /// holding the text), text box part). See [`link_text_boxes`]. pub txbx_links: Vec<(u32, u32, u32)>, @@ -125,17 +131,24 @@ pub fn read(bytes: &[u8]) -> Result { if crate::is_encrypted(bytes) { return crate::read_with_password(bytes, None); } - read_package(bytes) + read_package(bytes, None) } -/// [`read`] for bytes known not to be an encrypted package. -pub(crate) fn read_package(bytes: &[u8]) -> Result { - read_opened(&Package::open(bytes)?) +/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format +/// chunks in formats other than WordprocessingML. +pub(crate) fn read_package(bytes: &[u8], chunks: Option) -> Result { + read_opened(&Package::open(bytes)?, chunks) } /// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted /// Word 2003 XML document). -pub(crate) fn read_opened(pkg: &Package) -> Result { +pub(crate) fn read_opened(pkg: &Package, chunks: Option) -> Result { + read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc) +} + +/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and +/// graphic work left of that. +fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), DocxError> { let root_rels = pkg.rels(""); let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string()); let Some(root) = pkg.xml_strict(&main)? else { @@ -164,9 +177,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { comments_ended: HashSet::new(), bookmarks: HashMap::new(), perms: HashMap::new(), + next_perm: 0, graphic_parts: HashMap::new(), graphics: HashMap::new(), - graphic_budget: MAX_GRAPHIC_WORK, glued: HashSet::new(), shape_ids: false, main: main.clone(), @@ -175,6 +188,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { txbx_links: Vec::new(), theme_fills: Default::default(), group_fill: None, + chunks, }; r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone(); @@ -267,7 +281,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if let Some(body) = root.child("w:body") { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); - r.read_blocks(&mut sc, body, &rels, &mut blocks, 0); + r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth); r.flush_pending(&mut sc, &mut blocks); r.doc.body = blocks; unstyle_toc_links(&mut r.doc); @@ -321,9 +335,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if r.shape_ids { keep_glued_ids(&mut r.doc, &r.glued); } + let (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic); let mut doc = r.doc; doc.ensure_nonempty(); - Ok(doc) + Ok((doc, left, bytes, graphic)) } /// Most move ranges open at once, and most move names tracked, per file. diff --git a/crates/docx/src/read/chunk.rs b/crates/docx/src/read/chunk.rs new file mode 100644 index 000000000..782a5d06e --- /dev/null +++ b/crates/docx/src/read/chunk.rs @@ -0,0 +1,183 @@ +//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain +//! text, another WordprocessingML document) that Word brings into the document where the chunk +//! stands when it opens the file. A nested WordprocessingML package is read here; other formats +//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one +//! the chunk is left out. + +use std::sync::Arc; + +use wordcraft_doc::{Block, Blocks, Document}; + +use super::story::StoryCtx; +use super::{Reader, part_of}; +use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt}; +use crate::xml::El; + +/// Most chunks brought in from one file (nested documents' chunks included). +pub(crate) const MAX_CHUNKS: usize = 256; +/// Deepest a WordprocessingML chunk may nest in another. +pub(crate) const MAX_CHUNK_NESTING: u8 = 4; + +/// An alternative format chunk's content, as a [`ChunkImporter`] gets it. +pub struct AltChunk<'a> { + /// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`, + /// `text/plain`…), empty when the package doesn't give one. + pub content_type: &'a str, + /// The part's name (`word/afchunk.mht`): its extension tells the format when the content type + /// doesn't. + pub part: &'a str, + pub bytes: &'a [u8], +} + +/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's +/// place; `None` leaves the chunk out. +pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option; + +/// How [`crate::read_with`] reads a package. +#[derive(Clone, Copy, Default)] +pub struct ReadOptions<'a> { + /// The password of an encrypted package. + pub password: Option<&'a str>, + /// Imports `w:altChunk` content in formats other than WordprocessingML. + pub chunks: Option>, +} + +impl Reader<'_> { + /// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it). + pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) { + let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return }; + let pkg = self.pkg; + let Some(bytes) = pkg.get(&part) else { + log::warn!("docx: alternative format chunk {part} is missing"); + return; + }; + // Every chunk's bytes count against one budget for the whole file, however often the same + // part is named and however deep chunks nest. + if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes { + log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out"); + return; + } + self.chunks.left -= 1; + self.chunks.bytes -= bytes.len() as u64; + let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string(); + let chunk = AltChunk { content_type: &ct, part: &part, bytes }; + // A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged). + let doc = if bytes.starts_with(b"PK\x03\x04") { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) }; + let Some(mut doc) = doc else { + log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in"); + return; + }; + // The chunk flows in the section it stands in; its own section breaks don't come along. + for b in &mut doc.body { + if let Block::Para(p) = &**b + && p.section.is_some() + && let Block::Para(p) = Arc::make_mut(b) + { + p.section = None; + } + } + let mut blocks = self.doc.adopt_blocks(doc, &mut self.next_perm); + // Bookmarks and comment marks read before the chunk stay before it. + self.flush_pending_before(sc, out, &mut blocks); + out.extend(blocks.into_iter().map(Arc::new)); + } + + /// A WordprocessingML chunk: its document, read with the same importer and what is left of + /// the budgets, its XML depth counting on from `depth`. + fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option { + if self.chunks.nesting >= MAX_CHUNK_NESTING { + log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out"); + return None; + } + let pkg = + Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?; + self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size()); + let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks }; + let (doc, left, bytes, graphic) = + super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?; + self.chunks.left = left; + self.chunks.bytes = bytes; + self.chunks.graphic = graphic; + Some(doc) + } +} + +/// What a reader may still bring in from chunks. +#[derive(Clone, Copy)] +pub(crate) struct ChunkState<'a> { + /// Chunks left in the file's budget ([`MAX_CHUNKS`]). + pub left: usize, + /// Chunk bytes (nested packages unpacked) left in the file's budget. + pub bytes: u64, + /// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents' + /// charts and diagrams count against it too. + pub graphic: usize, + /// How deep in nested WordprocessingML chunks this reader is. + pub nesting: u8, + /// The XML depth its stories start at (a nested document's: that of the chunk naming it). + pub depth: usize, + pub import: Option>, +} + +impl<'a> ChunkState<'a> { + pub fn new(import: Option>) -> Self { + ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import } + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use wordcraft_doc::{Block, Document, InlineObject}; + + use super::ChunkState; + use crate::package::Package; + + /// A package whose body is `body`, with the document relationship `rel` (id, type, target) to + /// the part `(name, bytes)`. + fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let rels = + |inner: &str| format!(r#"{inner}"#); + let rel = |id: &str, ty: &str, target: &str| { + format!(r#""#) + }; + let files: [(&str, Vec); 4] = [ + ("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()), + ("word/document.xml", format!(r#"{body}"#).into_bytes()), + ("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()), + (name, bytes.to_vec()), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() + } + + /// Whether the body's first paragraph's chart is drawn. + fn drawn(d: &Document) -> bool { + let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") }; + p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty())) + } + + #[test] + fn a_nested_documents_charts_count_against_the_files_graphic_budget() { + let chart = r#"12"#; + let drawing = r#""#; + let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes())); + let outer = package(r#""#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner)); + let pkg = Package::open(&outer).unwrap(); + let read = |graphic| { + let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap(); + (drawn(&d), left) + }; + let (shown, left) = read(super::super::MAX_GRAPHIC_WORK); + assert!(shown); + assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's"); + // With the file's budget spent, the nested document doesn't get one of its own. + assert_eq!(read(0), (false, 0)); + } +} diff --git a/crates/docx/src/read/meta.rs b/crates/docx/src/read/meta.rs index 9739f66dc..7f6e93fc3 100644 --- a/crates/docx/src/read/meta.rs +++ b/crates/docx/src/read/meta.rs @@ -1,6 +1,6 @@ use super::{ - Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, - PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, + Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels, + Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, }; impl Reader<'_> { @@ -120,7 +120,7 @@ impl Reader<'_> { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); self.current_note = Some((kind, id)); - self.read_blocks(&mut sc, n, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); self.current_note = None; if let Some(p) = self.doc.parts.get_mut(&id) @@ -152,7 +152,7 @@ impl Reader<'_> { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); self.in_note_sep = true; - self.read_blocks(&mut sc, n, rels, &mut blocks, 0); + self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); self.in_note_sep = false; if blocks.is_empty() { @@ -188,11 +188,7 @@ impl Reader<'_> { let rels = self.pkg.rels(path); let mut by_para: HashMap = HashMap::new(); let mut next_id = 0u32; - let mut entries: BTreeMap = BTreeMap::new(); - // Ids given before this part, looked up rather than scanned for every generated id: a - // scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in - // `entries`. - let given: HashSet = self.comment_map.values().copied().collect(); + // Straight into the document, beside the comments chunks in notes brought in. for c in root.children("w:comment") { if self.doc.parts.len() >= MAX_PARTS { break; @@ -202,23 +198,26 @@ impl Reader<'_> { continue; } let id = match file_id.parse::() { - Ok(v) if !entries.contains_key(&v) => v, + Ok(v) if !self.doc.comments.contains_key(&v) => v, _ => { - while entries.contains_key(&next_id) || given.contains(&next_id) { + // Every id handed out so far is a key of `doc.comments` (inserted below). + while self.doc.comments.contains_key(&next_id) { next_id += 1; } next_id } }; + // Taken before its text is read: a chunk there brings comments of its own. + self.doc.comments.insert(id, Comment::default()); let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); - self.read_blocks(&mut sc, c, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); let part = self.doc.add_part(PartKind::Comment, blocks); if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) { by_para.insert(pid.to_string(), id); } - entries.insert( + self.doc.comments.insert( id, Comment { author: c.attr("w:author").unwrap_or("").to_string(), @@ -239,7 +238,7 @@ impl Reader<'_> { let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue }; let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid); let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true"); - if let Some(c) = entries.get_mut(&cid) { + if let Some(c) = self.doc.comments.get_mut(&cid) { c.parent = parent; c.resolved = done; } @@ -258,7 +257,7 @@ impl Reader<'_> { for e in x.els().filter(|e| e.local() == "commentId") { let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue }; let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue }; - if let Some(c) = entries.get_mut(&cid) { + if let Some(c) = self.doc.comments.get_mut(&cid) { c.durable_id = Some(durable.to_ascii_uppercase()); by_durable.insert(durable.to_ascii_uppercase(), cid); } @@ -270,12 +269,11 @@ impl Reader<'_> { for e in x.els().filter(|e| e.local() == "commentExtensible") { let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue }; let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c))); - if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) { + if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) { c.date_utc = Some(date); } } } - self.doc.comments = entries; Ok(()) } diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index f5ce13834..2e1f98e32 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -144,7 +144,7 @@ impl Reader<'_> { sc.in_hf = true; let mut blocks = Blocks::new(); self.hf_depth += 1; - self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth); self.hf_depth = self.hf_depth.saturating_sub(1); self.flush_pending(&mut sc, &mut blocks); if let Some(p) = self.doc.parts.get_mut(&id) diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index b1b26ca83..13f6f3e15 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -51,6 +51,7 @@ impl Reader<'_> { } } "w:sdt" => self.read_block_sdt(sc, e, rels, out, depth), + "w:altChunk" => self.read_alt_chunk(sc, e, rels, out, depth), "w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1), // Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements, // which takes EG_MathContent): it gets a paragraph of its own (#1273). @@ -135,6 +136,21 @@ impl Reader<'_> { sc.pending.clear(); } + /// Markers read before content placed whole (a chunk's `next`) stay before it: at the end of + /// the paragraph before, or after a table or with none, at the start of the content's first + /// paragraph, as a paragraph read there would take them (with a table first, the next + /// paragraph read takes them). + pub fn flush_pending_before(&mut self, sc: &mut StoryCtx, out: &mut Blocks, next: &mut [Block]) { + if out.last().is_some_and(|b| matches!(**b, Block::Para(_))) { + return self.flush_pending(sc, out); + } + if let Some(Block::Para(p)) = next.first_mut() { + for o in sc.pending.drain(..).rev() { + let _ = p.insert_object(0, o, &CharProps::default()); + } + } + } + pub(super) fn marker(&mut self, e: &El) -> Option { let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string(); match e.name.as_str() { @@ -161,8 +177,15 @@ impl Reader<'_> { if self.perms.len() >= 100_000 { return None; } - let n = self.perms.len() as u32; - let id = *self.perms.entry(id).or_insert(n); + let id = match self.perms.get(&id) { + Some(n) => *n, + None => { + let n = self.next_perm; + self.next_perm = n.saturating_add(1); + self.perms.insert(id, n); + n + } + }; let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string(); let (group, editor) = (text("w:edGrp"), text("w:ed")); if group.is_empty() && editor.is_empty() { diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index d6c05176b..22bcae2d5 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -218,8 +218,8 @@ impl Reader<'_> { if let Some(g) = self.graphics.get(&key) { return g.clone(); } - let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) }; - self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items)); + let items = if self.chunks.graphic == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) }; + self.chunks.graphic = self.chunks.graphic.saturating_sub(graphic_work(&items)); // A chart or SmartArt that is exactly what WordCraft writes for its model can be edited // (and is written from the model); any other stays as it was read. let chart = match kind { @@ -556,7 +556,7 @@ impl Reader<'_> { let mut blocks = Blocks::new(); // Shapes in the text aren't members of a group the box may be in. let group = self.group_fill.take(); - self.read_blocks(&mut inner, content, rels, &mut blocks, 0); + self.read_blocks(&mut inner, content, rels, &mut blocks, self.chunks.depth); self.group_fill = group; self.flush_pending(&mut inner, &mut blocks); self.doc.add_part(PartKind::TextBox, blocks) diff --git a/crates/docx/src/wordml2003.rs b/crates/docx/src/wordml2003.rs index e876ef9f5..61798d810 100644 --- a/crates/docx/src/wordml2003.rs +++ b/crates/docx/src/wordml2003.rs @@ -33,7 +33,7 @@ const MAX_STORIES: usize = 50_000; /// Read a Word 2003 XML Document. pub fn read(bytes: &[u8]) -> Result { - crate::read::read_opened(&convert(bytes)?) + crate::read::read_opened(&convert(bytes)?, None) } /// Parts the converted package gets relationships for. diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs new file mode 100644 index 000000000..9e0107682 --- /dev/null +++ b/crates/docx/tests/it/alt_chunks.rs @@ -0,0 +1,277 @@ +//! Alternative format chunks (`w:altChunk`, ECMA-376 §17.17.2.1): content in another format that +//! is brought in where the chunk stands. All markup here is synthetic. + +use std::io::Write; + +use wordcraft_doc::{Block, Document, InlineObject, StoryRef}; +use wordcraft_docx::{AltChunk, ReadOptions, read, read_with}; + +const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; +const ROOT_RELS: &str = r#""#; +const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; + +fn p(text: &str) -> String { + format!(r#"{text}"#) +} + +/// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`. +fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec { + let doc = format!(r#"{body}"#); + let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; + let mut types = String::from( + r#""#, + ); + if let Some((ext, ct, bytes)) = chunk { + types.push_str(&format!(r#""#)); + files.push(( + "word/_rels/document.xml.rels".into(), + format!(r#""#).into_bytes(), + )); + files.push((format!("word/afchunk.{ext}"), bytes.to_vec())); + } + types.push_str(r#""#); + files.push(("[Content_Types].xml".into(), types.into_bytes())); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +const CHUNK: &str = r#""#; + +fn body_lines(d: &Document) -> Vec { + d.plain_text(StoryRef::Body).split('\n').map(str::to_string).collect() +} + +#[test] +fn a_chunk_comes_in_through_the_importer_where_it_stands() { + let html = b"

Chunk one

Chunk two

"; + let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("htm", "application/xhtml+xml", html))); + let seen = std::cell::RefCell::new(Vec::new()); + let import = |c: &AltChunk| { + seen.borrow_mut().push((c.content_type.to_string(), c.part.to_string(), c.bytes.to_vec())); + Some(Document::from_text("Chunk one\nChunk two")) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["Before", "Chunk one", "Chunk two", "After"]); + assert_eq!(*seen.borrow(), [("application/xhtml+xml".to_string(), "word/afchunk.htm".to_string(), html.to_vec())]); + // Without an importer the chunk is left out. + assert_eq!(body_lines(&read(&bytes).unwrap()), ["Before", "After"]); +} + +#[test] +fn a_bookmark_opened_before_a_chunk_at_the_start_of_the_body_is_kept() { + let body = format!(r#"{CHUNK}{}"#, p("After")); + let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); + let import = |_: &AltChunk| Some(Document::from_text("Chunk")); + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["Chunk", "After"]); + let marks = d.bookmarks(); + assert_eq!(marks.len(), 1); + assert_eq!((marks[0].0.as_str(), marks[0].1.path.last(), marks[0].1.off), ("Here", 0, 0)); +} + +#[test] +fn a_bookmark_opened_between_a_table_and_a_chunk_starts_the_chunk() { + let table = format!("{}", p("Cell")); + let body = format!(r#"{table}{CHUNK}{}"#, p("After")); + let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); + let import = |_: &AltChunk| Some(Document::from_text("Chunk")); + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + let marks = d.bookmarks(); + assert_eq!(marks.len(), 1); + assert_eq!((marks[0].1.path.clone(), marks[0].1.off), (wordcraft_doc::Path::top(1), 0), "not in the table's cell"); +} + +#[test] +fn permission_ranges_of_chunks_and_their_host_stay_apart() { + let inner = package(r#"Inner"#, None); + // The host's range 0 is open across two chunks whose own ranges are 0 too. + let body = format!( + r#"Host{CHUNK}{CHUNK}After"# + ); + let d = read(&package(&body, Some(("docx", DOCX_MAIN, &inner)))).unwrap(); + assert_eq!(body_lines(&d), ["Host", "Inner", "Inner", "After"]); + let check = |d: &Document| { + let ranges = d.perm_ranges(); + let mut ids: Vec = ranges.iter().map(|r| r.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 3, "{ranges:?}"); + let host = ranges.iter().find(|r| r.group == "everyone").unwrap(); + assert_eq!((host.start.path.last(), host.end.path.last()), (0, 3)); + assert!(ranges.iter().filter(|r| r.editor == "them").all(|r| r.start.path == r.end.path)); + }; + check(&d); + check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +/// A package whose body is `body`, with story parts (relationship type, part name, XML) and the +/// Word document `chunk` as `rIdC` from the main part and from each story part. +fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Option<&[u8]>) -> Vec { + let rel = |id: &str, ty: &str, target: &str| { + format!(r#""#) + }; + let rels = + |inner: String| format!(r#"{inner}"#); + let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() }; + let doc = format!(r#"{body}"#); + let mut main_rels = chunk_rel.clone(); + let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; + for (i, (ty, name, xml)) in stories.iter().enumerate() { + main_rels.push_str(&rel(&format!("rIdS{i}"), ty, name)); + files.push((format!("word/{name}"), xml.clone().into_bytes())); + files.push((format!("word/_rels/{name}.rels"), rels(chunk_rel.clone()).into_bytes())); + } + files.push(("word/_rels/document.xml.rels".into(), rels(main_rels).into_bytes())); + if let Some(c) = chunk { + files.push(("word/afchunk.docx".into(), c.to_vec())); + } + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A paragraph `text` that comment `id` (in the file) is anchored around. +fn commented(id: u32, text: &str) -> String { + format!( + r#"{text}"# + ) +} + +/// A comments part: (file id, author, content). +fn comments_part(list: &[(u32, &str, String)]) -> (&'static str, &'static str, String) { + let body: String = list.iter().map(|(id, who, content)| format!(r#"{content}"#)).collect(); + ("comments", "comments.xml", format!("{body}")) +} + +/// The authors of the comments anchored in the stories whose text contains `text`, and whether +/// every comment has text of its own and a distinct id. +fn anchored(d: &Document, text: &str) -> Vec { + let mut stories: Vec<&[std::sync::Arc]> = vec![&d.body]; + stories.extend(d.parts.values().map(|p| p.blocks.as_slice())); + let mut out = Vec::new(); + for blocks in stories { + for b in blocks { + let Block::Para(p) = &**b else { continue }; + if !p.text.contains(text) { + continue; + } + for o in &p.objects { + if let InlineObject::CommentStart { id } = o { + out.push(d.comments.get(id).map_or("?".to_string(), |c| c.author.clone())); + } + } + } + } + out +} + +/// A Word document with one comment by "Them" around "Inner". +fn commented_chunk() -> Vec { + package_with_stories(&commented(0, "Inner"), &[comments_part(&[(0, "Them", p("Theirs"))])], None) +} + +#[test] +fn a_chunk_in_a_footnote_keeps_its_comments_beside_the_documents() { + let notes = format!(r#""#); + let body = format!(r#"{}"#, commented(0, "Host")); + let stories = [("footnotes", "footnotes.xml", notes), comments_part(&[(0, "Us", p("Ours"))])]; + let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); + assert_eq!(d.comments.len(), 2, "{:?}", d.comments); + assert_eq!(anchored(&d, "Host"), ["Us"]); + assert_eq!(anchored(&d, "Inner"), ["Them"]); +} + +#[test] +fn a_chunk_in_a_comment_keeps_its_comments_beside_the_documents() { + let inside = format!(r#"{}"#, p("Ours")); + let body = format!("{}{}", commented(0, "Host"), commented(1, "Second")); + let stories = [comments_part(&[(0, "Us", inside), (1, "Us too", p("Ours too"))])]; + let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); + assert_eq!(d.comments.len(), 3, "{:?}", d.comments); + assert_eq!(anchored(&d, "Host"), ["Us"]); + assert_eq!(anchored(&d, "Second"), ["Us too"]); + assert_eq!(anchored(&d, "Inner"), ["Them"]); +} + +#[test] +fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() { + let inner = package(&format!("{}{}", p("Inner one"), p("Inner two")), None); + let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("docx", DOCX_MAIN, &inner))); + let d = read(&bytes).unwrap(); + assert_eq!(body_lines(&d), ["Before", "Inner one", "Inner two", "After"]); + // The inner document's own section doesn't come along. + assert_eq!(d.sections().len(), 1); +} + +#[test] +fn nested_wordprocessing_chunks_stop_at_a_depth() { + let mut bytes = package(&p("Deepest"), None); + for level in 0..8 { + bytes = package(&format!("{}{CHUNK}", p(&format!("Level {level}"))), Some(("docx", DOCX_MAIN, &bytes))); + } + let lines = body_lines(&read(&bytes).unwrap()); + assert_eq!(lines.first().map(String::as_str), Some("Level 7")); + assert!(!lines.iter().any(|l| l == "Deepest"), "{lines:?}"); + assert!(lines.len() >= 4, "{lines:?}"); +} + +#[test] +fn a_chunks_pictures_come_with_it() { + let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("htm", "text/html", b"

x

"))); + let import = |_: &AltChunk| { + let mut d = Document::new(); + let key = d.add_media(vec![1, 2, 3], "png"); + let mut para = wordcraft_doc::Paragraph::new(); + let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + para.insert_object(0, image, &Default::default()).unwrap(); + d.body = vec![std::sync::Arc::new(Block::Para(para))]; + Some(d) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + let keys: Vec = d + .body + .iter() + .filter_map(|b| match &**b { + Block::Para(p) => p.objects.iter().find_map(|o| match o { + InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(keys.len(), 1); + assert_eq!(d.media.get(&keys[0]).map(|m| m.as_slice()), Some(&[1u8, 2, 3][..])); +} + +#[test] +fn a_file_brings_in_a_bounded_number_of_chunks() { + let bytes = package(&CHUNK.repeat(300), Some(("htm", "text/html", b"

x

"))); + let calls = std::cell::Cell::new(0); + let import = |_: &AltChunk| { + calls.set(calls.get() + 1); + Some(Document::from_text("x")) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(calls.get(), 256); + assert_eq!(body_lines(&d).len(), 256); +} + +#[test] +fn hostile_chunks_never_panic() { + for (ext, ct, bytes) in [("docx", DOCX_MAIN, &b"PK\x03\x04 not a zip"[..]), ("htm", "text/html", b""), ("bin", "", b"\x00\xff")] { + let bytes = package(&format!("{CHUNK}{CHUNK}{}", p("x")), Some((ext, ct, bytes))); + let import = |_: &AltChunk| None; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["x"]); + } + // A chunk naming a missing part or another relationship type is left out. + let bytes = package(&format!(r#"{}"#, p("y")), None); + assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index e7134e11d..dda0e4ffe 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -1,3 +1,4 @@ +mod alt_chunks; mod bidi; mod bidi_runs; mod cell_margin_inheritance; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 57aaabdd5..c5c4a1240 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -134,7 +134,8 @@ pub fn specs() -> Vec { CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { let path = p::req_str(v, "path")?; let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; - let mut frag = doc.body_fragment(); + // The file's pictures, lists and styles come along (its own keys and ids mean nothing here). + let mut frag = s.doc.adopt(doc); super::paste::unlink_sections(&mut frag.blocks); let at = delete_selection(s)?; let end = s.doc.insert_fragment(&at, &frag)?; diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 5327e8e02..4a8aad3b0 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -8,7 +8,6 @@ //! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687). //! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does. -use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; @@ -401,35 +400,6 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para } } -/// Point a tracked formatting change at its renumbered revision (dropped when it has none). -fn remap_change(c: &mut Option>>, rev: &dyn Fn(&mut Option)) { - let mut r = c.as_ref().map(|c| c.rev); - rev(&mut r); - match (r, c.as_mut()) { - (Some(v), Some(x)) => x.rev = v, - _ => *c = None, - } -} - -/// [`remap_change`] for the table, row and cell formatting changes in `b` (bounded depth). -fn remap_table_changes(b: &mut Block, depth: usize, rev: &dyn Fn(&mut Option)) { - if depth > MAX_DEPTH { - return; - } - if let Block::Table(t) = b { - remap_change(&mut t.props.fmt_change, rev); - for row in &mut t.rows { - remap_change(&mut row.props.fmt_change, rev); - for cell in &mut row.cells { - remap_change(&mut cell.props.fmt_change, rev); - for cb in &mut cell.blocks { - remap_table_changes(Arc::make_mut(cb), depth + 1, rev); - } - } - } - } -} - /// Section breaks brought in from another document keep their page, but their header and /// footer ids are that document's: clear them (and the different first page that went with /// them) so the sections link to the previous section's headers and footers, as in Word. @@ -448,81 +418,12 @@ pub(crate) fn unlink_sections(blocks: &mut [Block]) { /// Turn an imported document into a fragment of the session's document: bring over the media, /// lists and styles it refers to (renumbered so they can't collide with ours). fn adopt(s: &mut Session, src: Document) -> Fragment { - let mut media = BTreeMap::new(); - for (key, data) in &src.media { - let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); - media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext)); - } - let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); - let mut abs_map = BTreeMap::new(); - for (i, a) in src.numbering.abstracts.iter().enumerate() { - let id = next_abs.saturating_add(i as u32); - abs_map.insert(a.id, id); - s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() }); - } - let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); - let mut num_map = BTreeMap::new(); - for (i, n) in src.numbering.nums.iter().enumerate() { - let id = next_num.saturating_add(i as u32); - num_map.insert(n.id, id); - let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); - s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() }); - } - for st in &src.styles.styles { - if s.doc.styles.get(&st.id).is_none() { - s.doc.styles.upsert(st.clone()); - } - } - // Tracked changes the source carries (RTF revisions) point at its own revisions: add them - // after ours and renumber. - let rev_base = u32::try_from(s.doc.revisions.len()).unwrap_or(u32::MAX); - let src_revs = src.revisions.len(); - s.doc.revisions.extend(src.revisions.iter().cloned()); - let rev = |i: &mut Option| { - *i = i.filter(|v| (*v as usize) < src_revs).and_then(|v| rev_base.checked_add(v)); - }; - let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + let mut frag = s.doc.adopt(src); // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). let max_w = crate::io::text_width(&s.doc); - for b in &mut blocks { + for b in &mut frag.blocks { crate::io::fit_block_pictures(b, max_w); } - for b in &mut blocks { - each_para(b, 0, &mut |p| { - if let Some(n) = p.props.numbering.as_mut() - && n.num != 0 - { - n.num = num_map.get(&n.num).copied().unwrap_or(0); - } - for r in &mut p.runs { - rev(&mut r.props.ins); - rev(&mut r.props.del); - remap_change(&mut r.props.fmt_change, &rev); - } - rev(&mut p.mark.ins); - rev(&mut p.mark.del); - remap_change(&mut p.mark.fmt_change, &rev); - remap_change(&mut p.props.fmt_change, &rev); - let mut n = p.props.num_change.as_ref().map(|c| c.rev); - rev(&mut n); - match (n, p.props.num_change.as_mut()) { - (Some(v), Some(c)) => c.rev = v, - _ => p.props.num_change = None, - } - for o in &mut p.objects { - if let wordcraft_doc::InlineObject::Image { media: key, .. } = o - && let Some(k) = media.get(key.as_str()) - { - *key = k.clone(); - } - } - }); - } - for b in &mut blocks { - remap_table_changes(b, 0, &rev); - } - unlink_sections(&mut blocks); - // Text box stories the imported shapes show; pasting gives each a new story of its own. - let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); - Fragment { blocks, parts } + unlink_sections(&mut frag.blocks); + frag } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 960255030..cbe8e4c03 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -18,6 +18,8 @@ pub const OPEN_EXTS: &[&str] = &[ "markdown", "html", "htm", + "mht", + "mhtml", "rtf", "odt", "tex", @@ -201,7 +203,7 @@ fn load_embedded_fonts(doc: &Document) { fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { - let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?; + let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; opened(&mut doc); return Ok((doc, true)); } @@ -230,6 +232,42 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft Ok((doc, false)) } +/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its +/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. +pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { + wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) +} + +/// An alternative format chunk in a format the docx reader leaves to the format importers, as a +/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's +/// extension or what it holds. Its pictures fit the text width, as when the file is opened. +fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { + use wordcraft_formats::{html, mht, rtf, txt}; + let full = c.content_type.to_ascii_lowercase(); + let ct = full.split(';').next().unwrap_or("").trim(); + let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); + let ext = ext_of(c.part); + let b = c.bytes; + let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") + || ct.starts_with("multipart/") + || matches!(ext.as_str(), "mht" | "mhtml") + || mht::is_mht(b) + { + mht::import(b) + } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { + rtf::import(b).ok()? + } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { + html::import_page(&html::decode_as(b, charset), &|_| None) + } else if ct == "text/plain" || ext == "txt" { + txt::import(b) + } else { + return None; + }; + // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. + fit_pictures(&mut doc); + Some(doc) +} + /// The narrowest text width of the document's sections, points: what a picture must fit. pub(crate) fn text_width(doc: &Document) -> f32 { doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) @@ -270,7 +308,7 @@ pub(crate) fn fit_pictures(doc: &mut Document) { /// Does the file hold a different document format than its Word or RTF extension says? An RTF /// file saved as `.doc` (as accounting systems and report generators often save their /// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML -/// Document (2003 or Flat OPC) or a web page saved as `.doc` (#2853): Word opens +/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens /// each by what it holds, so they go through [`open_by_content`] instead of failing. fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { let expected = match ext { @@ -289,6 +327,8 @@ fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { "xml" } else if looks_like_web_page(bytes) { "html" + } else if wordcraft_formats::mht::is_mht(bytes) { + "mht" } else { return false; }; @@ -346,9 +386,9 @@ fn open_by_content( page: &wordcraft_doc::SectionProps, ) -> Result<(Document, bool), String> { let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { - (wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true) + (read_docx(bytes, password).map_err(|e| e.to_string())?, true) } else if bytes.starts_with(ZIP_SIG) { - (wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) + (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) } else if bytes.starts_with(OLE_SIG) { open_binary_doc(bytes, password) .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? @@ -359,6 +399,9 @@ fn open_by_content( } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); (opened?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { + let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); + (opened?, false) } else if wordcraft_formats::txt::is_text(bytes) { (wordcraft_formats::txt::import_on(bytes, page), false) } else if ext.is_empty() { diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 762ac423a..6622ae664 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -6,7 +6,7 @@ use wordcraft_doc::Document; /// start on `page`. pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { match ext { - "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), + "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 506033cb1..7dabdf0af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11599,3 +11599,140 @@ fn export_png_rejects_a_page_number_that_does_not_fit() { assert!(!target.exists(), "no file is written for an invalid page"); let _ = std::fs::remove_dir_all(&dir); } + +/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. +#[cfg(not(target_arch = "wasm32"))] +fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { + use std::io::Write; + let files: [(String, Vec); 4] = [ + ( + "[Content_Types].xml".into(), + format!(r#""#).into_bytes(), + ), + ("_rels/.rels".into(), br#""#.to_vec()), + ( + "word/_rels/document.xml.rels".into(), + format!(r#""#).into_bytes(), + ), + ( + "word/document.xml".into(), + format!(r#"{body}"#).into_bytes(), + ), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. +#[test] +fn a_single_file_web_page_saved_as_doc_opens_by_content() { + let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; + for name in ["report.doc", "report.docx", "report.rtf"] { + let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn alt_chunks_open_in_place_as_word_brings_them_in() { + let png = wordcraft_formats::model::base64_encode(&tiny_png()); + let mht = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ + Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

=C4=8Clanak 1.

\r\n\ + --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" + ); + let p = |t: &str| format!(r#"{t}"#); + let chunk = r#""#; + let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); + let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); + let text = doc.plain_text(StoryRef::Body); + assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); + let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); + // Web page text: Times New Roman unless the page says otherwise. + assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); + // The picture came along, its bytes under the key it shows. + let key = doc + .body + .iter() + .find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .expect("the chunk's picture"); + assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); + + // HTML (in its content type's charset), RTF and plain-text chunks come in through their + // importers too, and an MHT chunk is known by what it holds. + for (ext, ct, data, want) in [ + ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), + ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), + ("txt", "text/plain", b"Plain chunk", "Plain chunk"), + ("bin", "", mht.as_bytes(), "Članak 1."), + ] { + let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); + let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); + assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_from_file_brings_the_files_pictures() { + let dir = scratch_dir("text-from-file-pictures"); + let mut src = Session::new(wordcraft_doc::Document::new()); + let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet); + // A list item, then a paragraph (a single paragraph would take the caret's formatting). + src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = + Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); + let key = src.doc.add_media(tiny_png(), "png"); + let image = + wordcraft_doc::InlineObject::Image { media: key, w: 20.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); + let path = dir.join("pictures.docx"); + std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); + + let mut s = s(); + // A picture and a list of our own first, so the file's key and list id would point at ours. + let ours = s.doc.add_media(vec![9, 9, 9], "png"); + let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered); + run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_dir_all(&dir); + let shown: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(shown.len(), 1); + assert_ne!(shown[0], ours); + assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); + let listed: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), + _ => None, + }) + .collect(); + assert_eq!(listed.len(), 1); + assert_ne!(listed[0], our_list); + assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 1c2c79538..a4f4bfd06 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -8,18 +8,22 @@ //! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS //! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`, //! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page -//! breaks. Scripts and styles are skipped; nesting is capped. +//! breaks, and on paragraphs `margin`, `text-indent` and `line-height`. CSS comes from `style` +//! attributes and from `style` elements' rules with simple selectors (`p`, `.note`, `p.note`), +//! applied in cascade order (specificity, then source order, then the `style` attribute). +//! Scripts are skipped; nesting is capped. //! //! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as //! ``. use std::sync::Arc; +use wordcraft_doc::props::LineSpacing; use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ - self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, base64_encode, data_uri, make_img, mime_of, parse_color, + self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, Spacing, base64_encode, data_uri, make_img, mime_of, parse_color, }; mod builder; @@ -32,9 +36,11 @@ mod math_ruby; pub use builder::ImageLoader; pub use css::css_len_pt; -pub use entity::{decode, decode_entity, unescape}; +pub use entity::{decode, decode_as, decode_entity, unescape}; pub use export::{esc, export, export_flow, export_fragment, inlines_html}; -pub use import::{import, import_on, import_with, parse, parse_with}; +#[cfg(test)] +use import::parse_in; +pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with}; #[cfg(test)] mod tests; diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index 5de71edd6..d487bd1ed 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -1,6 +1,9 @@ -use super::css::{apply_style, css_len_pt}; -use super::import::collapse_ws; -use super::{Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, VAlign, data_uri, make_img, model, parse_color}; +use super::css::{apply_style, css_len_pt, strip_important}; +use super::import::{PAGE_FONT, PAGE_HEADING_SIZES, collapse_ws}; +use super::{ + Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, LineSpacing, ListInfo, Meta, Para, Spacing, VAlign, data_uri, make_img, model, + parse_color, +}; /// Open elements kept at most (deeper start tags are ignored). const MAX_STACK: usize = 256; @@ -28,6 +31,12 @@ struct El { name: String, kind: ElKind, fmt: Fmt, + /// Margins, indents and line height its CSS states (blocks only; they don't inherit; on a + /// page's spans, their margins). + spacing: Spacing, + /// A page's `p` or heading: margins its CSS leaves out (or writes invalidly) are HTML's + /// automatic spacing. + auto_margins: bool, align: Option, preserve: bool, page_break: bool, @@ -77,20 +86,37 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) { } } -/// Whether an element's `border` attribute or `style` declares a visible border. -fn declares_border(attrs: &[(String, String)], is_table: bool) -> bool { +/// Whether an element's `border` attribute or its CSS declares a visible border. +fn declares_border(attrs: &[(String, String)], css: &str, is_table: bool) -> bool { if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::().map(|n| n > 0).unwrap_or(b.trim().is_empty())) { return true; } - let Some(style) = attr(attrs, "style") else { return false }; - style.split(';').any(|decl| { - let Some((name, value)) = decl.split_once(':') else { return false }; - let name = name.trim().to_ascii_lowercase(); - if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") { - return false; + // Each side's last declaration decides (the `style` attribute's come after the sheet's). + let mut sides = [false; 4]; + for (name, value) in decls(css) { + let set = match name.as_str() { + "border" => 0..4, + "border-top" => 0..1, + "border-right" => 1..2, + "border-bottom" => 2..3, + "border-left" => 3..4, + _ => continue, + }; + let value = value.to_ascii_lowercase(); + let on = !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt")); + if let Some(s) = sides.get_mut(set) { + s.fill(on); } - let value = value.trim().to_ascii_lowercase(); - !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt")) + } + sides.contains(&true) +} + +/// A declaration block's declarations, at most [`MAX_DECLS`]: (property in lower case, value +/// without `!important`). +pub(super) fn decls(css: &str) -> impl Iterator { + css.split(';').take(MAX_DECLS).filter_map(|d| { + let (k, v) = d.split_once(':')?; + Some((k.trim().to_ascii_lowercase(), strip_important(v))) }) } @@ -116,6 +142,12 @@ pub(super) struct Builder<'r> { pre_tail: bool, meta: Meta, title: Option, + /// Rules of the page's `style` elements. + pub(super) sheet: Sheet, + /// Read as Word opens a web page ([`import_page`]), not as a pasted fragment. + page: bool, + /// What is left of [`MAX_CSS_WORK`]. + css_work: usize, } fn is_void(n: &str) -> bool { @@ -196,12 +228,310 @@ fn closes_p(n: &str) -> bool { ) } +/// The paragraph spacing a block's CSS states: `margin` (and its sides) as space before / after +/// and left / right indents, `text-indent` as the first-line indent, `line-height` as line +/// spacing (a number or percentage: a multiple; a length: exactly). `auto` and values that don't +/// parse state nothing. +fn block_spacing(css: &str) -> Spacing { + let mut sp = Spacing::default(); + // A length with a unit, or 0: a bare number, a percentage or a keyword states nothing. + let len = |v: &str| match v.trim() { + "0" => Some(0.0), + v if v.ends_with('%') || v.parse::().is_ok() || !v.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')) => None, + v => css_len_pt(v).filter(|x| x.abs() <= 1584.0), + }; + for (k, v) in decls(css) { + match k.as_str() { + "margin" => { + let parts: Vec> = v.split_whitespace().map(len).collect(); + let [top, right, bottom, left] = match parts.as_slice() { + [a] => [*a; 4], + [a, b] => [*a, *b, *a, *b], + [a, b, c] => [*a, *b, *c, *b], + [a, b, c, d] => [*a, *b, *c, *d], + _ => continue, + }; + sp.before = top.map(|x| x.max(0.0)).or(sp.before); + sp.after = bottom.map(|x| x.max(0.0)).or(sp.after); + sp.left = left.or(sp.left); + sp.right = right.or(sp.right); + } + "margin-top" => sp.before = len(v).map(|x| x.max(0.0)).or(sp.before), + "margin-bottom" => sp.after = len(v).map(|x| x.max(0.0)).or(sp.after), + "margin-left" => sp.left = len(v).or(sp.left), + "margin-right" => sp.right = len(v).or(sp.right), + "text-indent" => sp.first = len(v).or(sp.first), + "line-height" => { + let lv = v.to_ascii_lowercase(); + let multiple = |m: f32| (m.is_finite() && m > 0.0).then(|| LineSpacing::Multiple(m.clamp(0.25, 10.0))); + sp.line = if let Some(pc) = lv.strip_suffix('%') { + pc.trim().parse::().ok().and_then(|p| multiple(p / 100.0)) + } else if let Ok(m) = lv.parse::() { + multiple(m) + } else if lv == "normal" { + Some(LineSpacing::Multiple(1.0)) + } else { + len(&lv).filter(|x| *x > 0.0).map(LineSpacing::Exactly) + } + .or(sp.line); + } + _ => {} + } + } + sp +} + +/// A rule of a `style` element with a simple selector: an element name, classes or both. +#[derive(Clone, Debug)] +struct Rule { + tag: Option, + classes: Vec, + /// Classes count ten, an element name one. + specificity: usize, + /// Shared by the selectors of one block. + decls: Arc, + /// How many declarations `decls` holds. + count: usize, +} + +/// Most rules kept from a page's style elements. +const MAX_RULES: usize = 10_000; +/// Most declarations read for one element (its rules' and its `style` attribute's). +const MAX_DECLS: usize = 512; +/// Most declarations read from one `style` attribute. +const INLINE_DECLS: usize = 64; +/// Rule matching (rules looked at plus bytes gathered) one page may spend. +const MAX_CSS_WORK: usize = 64 << 20; +/// Most bytes of declarations gathered from the rules one element matches. +const MAX_RULE_CSS: usize = 64 * 1024; +/// Most of an element's classes looked up. +const MAX_CLASSES: usize = 32; + +/// A page's style rules, found by the element name or a class they need. +#[derive(Default)] +pub(super) struct Sheet { + rules: Vec, + /// Rules naming classes, by their first class. + by_class: std::collections::HashMap>, + /// Rules naming only an element, by its name. + by_tag: std::collections::HashMap>, + /// `*` rules. + any: Vec, +} + +impl Sheet { + fn push(&mut self, r: Rule) { + let i = self.rules.len(); + match (r.classes.first(), &r.tag) { + (Some(c), _) => self.by_class.entry(c.clone()).or_default().push(i), + (None, Some(t)) => self.by_tag.entry(t.clone()).or_default().push(i), + (None, None) => self.any.push(i), + } + self.rules.push(r); + } +} + +/// Read the rules of a style sheet into `out`. At-rules (`@media`, `@page`…) and rules whose +/// selectors aren't simple (descendants, pseudo-classes, attributes…) are skipped. +pub(super) fn parse_sheet(css: &str, out: &mut Sheet) { + // Comments go, as do the `` old pages hide their styles in. + let mut text = String::with_capacity(css.len()); + let mut rest = css; + while let Some(i) = rest.find("/*") { + text.push_str(rest.get(..i).unwrap_or("")); + rest = rest.get(i + 2..).and_then(|r| r.find("*/").and_then(|j| r.get(j + 2..))).unwrap_or(""); + } + text.push_str(rest); + let text = text.replace("", " "); + let mut rest = text.as_str(); + while let Some(open) = rest.find('{') { + // A statement at-rule before it (`@charset "x";`, `@import url(a);`) isn't a selector. + let selectors = rest.get(..open).unwrap_or("").rsplit(';').next().unwrap_or("").trim(); + let body = rest.get(open + 1..).unwrap_or(""); + // The block's end, counting nested braces (an at-rule's own rules). + let mut depth = 1usize; + let mut end = body.len(); + for (i, c) in body.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = i; + break; + } + } + _ => {} + } + } + let decls = body.get(..end).unwrap_or(""); + rest = body.get(end + 1..).unwrap_or(""); + if selectors.starts_with('@') || decls.contains('{') || decls.len() > MAX_RULE_CSS { + continue; + } + let (count, decls) = (decls.split(';').count(), Arc::::from(decls)); + for sel in selectors.split(',') { + if out.rules.len() >= MAX_RULES { + return; + } + if let Some((tag, classes)) = simple_selector(sel.trim()) { + let specificity = classes.len() * 10 + usize::from(tag.is_some()); + out.push(Rule { tag, classes, specificity, decls: decls.clone(), count }); + } + } + } +} + +/// `p`, `.note`, `p.note.wide` or `*`: (element name, classes). Class names may start with a +/// digit (`.000003`): browsers drop such a rule, but Word's HTML import applies it, and +/// converters write them. +fn simple_selector(sel: &str) -> Option<(Option, Vec)> { + if sel == "*" { + return Some((None, Vec::new())); + } + let mut parts = sel.split('.'); + let tag = parts.next()?; + let tag = if tag.is_empty() { + None + } else if tag.chars().all(|c| c.is_ascii_alphanumeric()) && tag.starts_with(|c: char| c.is_ascii_alphabetic()) { + Some(tag.to_ascii_lowercase()) + } else { + return None; + }; + let ident = |c: &str| !c.is_empty() && c.chars().all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-'); + let classes: Vec = parts.map(str::to_string).collect(); + if classes.iter().any(|c| !ident(c)) || (tag.is_none() && classes.is_empty()) { + return None; + } + Some((tag, classes)) +} + +impl Builder<'_> { + /// An element's CSS: the declarations of the style sheet rules it matches, in cascade order, + /// then its `style` attribute (later declarations win). + fn css_of(&mut self, name: &str, attrs: &[(String, String)]) -> String { + // The `style` attribute's first declarations always fit in what is read of the result. + let inline: Vec<&str> = attr(attrs, "style").unwrap_or("").split(';').take(INLINE_DECLS).collect(); + let inline = inline.join(";"); + let sheet = &self.sheet; + if sheet.rules.is_empty() || self.css_work == 0 { + return important_last(&inline); + } + let mut classes: Vec<&str> = attr(attrs, "class").map(|c| c.split_whitespace().take(MAX_CLASSES).collect()).unwrap_or_default(); + classes.sort_unstable(); + classes.dedup(); + let mut candidates: Vec = sheet.any.clone(); + candidates.extend(sheet.by_tag.get(name).into_iter().flatten()); + for c in &classes { + candidates.extend(sheet.by_class.get(*c).into_iter().flatten()); + } + let mut hits: Vec<(usize, usize)> = candidates + .iter() + .filter_map(|&i| sheet.rules.get(i).map(|r| (i, r))) + .filter(|(_, r)| r.tag.as_deref().is_none_or(|t| t == name) && r.classes.iter().all(|c| classes.contains(&c.as_str()))) + .map(|(i, r)| (r.specificity, i)) + .collect(); + hits.sort_unstable(); + let mut css = String::new(); + let mut count = 0; + for (_, i) in hits { + let Some(r) = sheet.rules.get(i) else { continue }; + if css.len() + r.decls.len() > MAX_RULE_CSS || count + r.count > MAX_DECLS - INLINE_DECLS { + break; + } + css.push_str(&r.decls); + css.push(';'); + count += r.count; + } + // A page whose rules cost too much to apply reads on without them. + self.css_work = self.css_work.saturating_sub(candidates.len() + css.len()); + css.push_str(&inline); + important_last(&css) + } +} + +/// `css`'s declarations in order, the `!important` ones after the others: they win over normal +/// ones wherever they come from (CSS Cascade 5 §6.4), an inline one over the sheet's. +fn important_last(css: &str) -> String { + let (important, normal): (Vec<&str>, Vec<&str>) = css.split(';').filter(|d| !d.trim().is_empty()).partition(|d| strip_important(d) != d.trim()); + normal.into_iter().chain(important).collect::>().join(";") +} + +/// The borders a cell's CSS gives it (`border`, `border-top`…: style, width and colour in any +/// order; `windowtext` is the automatic colour). `None` when it states none. +fn cell_borders(css: &str) -> Option { + use wordcraft_doc::props::{Border, BorderStyle, Borders}; + let mut b = Borders::default(); + let mut any = false; + for (k, v) in decls(css) { + let sides: &[u8] = match k.as_str() { + "border" => b"tlbr", + "border-top" => b"t", + "border-left" => b"l", + "border-bottom" => b"b", + "border-right" => b"r", + _ => continue, + }; + // CSS's default width is `medium`. + let (mut style, mut width, mut color) = (None, 2.25_f32, None); + for tok in v.split_whitespace() { + let lt = tok.to_ascii_lowercase(); + match lt.as_str() { + "none" | "hidden" => style = Some(BorderStyle::None), + "solid" => style = Some(BorderStyle::Single), + "double" => style = Some(BorderStyle::Double), + "dotted" => style = Some(BorderStyle::Dotted), + "dashed" => style = Some(BorderStyle::Dashed), + "thin" => width = 0.75, + "medium" => width = 2.25, + "thick" => width = 3.75, + "windowtext" => {} + _ => match css_len_pt(tok).filter(|_| tok.starts_with(|c: char| c.is_ascii_digit() || c == '.')) { + Some(w) => width = w.clamp(0.0, 12.0), + None => color = parse_color(tok).or(color), + }, + } + } + // CSS draws no border without a style, or at no width. + let style = if width > 0.0 { style.unwrap_or(BorderStyle::None) } else { BorderStyle::None }; + let border = Border { style, width, color, space: 0.0 }; + for side in sides { + let slot = match side { + b't' => &mut b.top, + b'l' => &mut b.left, + b'b' => &mut b.bottom, + _ => &mut b.right, + }; + *slot = Some(border); + } + any = true; + } + any.then_some(b) +} + +/// Most columns given widths in one table. +const MAX_TABLE_COLS: usize = 63; + +/// A cell's width, points: its CSS `width`, else its `width` attribute (pixels); percentages +/// and widths that don't parse give none. +fn cell_width(css: &str, attrs: &[(String, String)]) -> Option { + let from_css = decls(css).filter(|(k, v)| k == "width" && !v.ends_with('%')).filter_map(|(_, v)| css_len_pt(v)); + let from_attr = || { + attr(attrs, "width") + .map(str::trim) + .filter(|v| !v.ends_with('%')) + .and_then(|v| v.trim_end_matches("px").trim().parse::().ok()) + .map(|px| px * 0.75) + }; + from_css.last().or_else(from_attr).filter(|w| w.is_finite() && *w > 1.0 && *w <= 1584.0) +} + fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> { attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) } impl<'r> Builder<'r> { - pub(super) fn new(images: ImageLoader<'r>) -> Builder<'r> { + pub(super) fn new(images: ImageLoader<'r>, page: bool) -> Builder<'r> { Builder { images, containers: vec![Vec::new()], @@ -215,11 +545,19 @@ impl<'r> Builder<'r> { pre_tail: false, meta: Meta::default(), title: None, + sheet: Sheet::default(), + page, + css_work: MAX_CSS_WORK, } } fn fmt(&self) -> Fmt { - self.stack.last().map(|e| e.fmt.clone()).unwrap_or_default() + match self.stack.last() { + Some(e) => e.fmt.clone(), + // A web page's text is Times New Roman 12 pt unless its CSS says otherwise. + None if self.page => Fmt { font: Some(PAGE_FONT.into()), size: Some(12.0), ..Fmt::default() }, + None => Fmt::default(), + } } fn preserve(&self) -> bool { @@ -237,6 +575,15 @@ impl<'r> Builder<'r> { let mut list_level: Option<(bool, usize)> = None; let mut li_at: Option = None; let mut lists = 0usize; + let mut spaced = false; + // A page's first span stating margins (see below). + let span = self + .stack + .iter() + .rev() + .take_while(|e| e.kind == ElKind::Inline) + .find(|e| e.spacing.before.is_some() || e.spacing.after.is_some()) + .map(|e| (e.spacing.before, e.spacing.after)); for (i, e) in self.stack.iter().enumerate().rev() { match e.kind { ElKind::Cell => { @@ -270,6 +617,27 @@ impl<'r> Builder<'r> { if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline { p.align = e.align; } + // Margins belong to the paragraph's own block, not to those around it. + if !spaced && e.kind != ElKind::Inline { + p.spacing = e.spacing; + if self.page { + // Word opens a page's paragraphs with single lines; a margin a span + // states replaces the one its paragraph states, and one the paragraph + // leaves out is automatic spacing. + let (top, bottom) = span.unwrap_or_default(); + let sp = &mut p.spacing; + sp.before_auto = sp.before.is_none() && e.auto_margins; + sp.after_auto = sp.after.is_none() && e.auto_margins; + sp.before = sp.before.map(|own| top.unwrap_or(own)); + sp.after = sp.after.map(|own| bottom.unwrap_or(own)); + sp.line = sp.line.or(Some(LineSpacing::Multiple(1.0))); + } + spaced = true; + } + } + // Page text without a block of its own (straight in a cell) is single-spaced too. + if self.page && !spaced { + p.spacing.line = Some(LineSpacing::Multiple(1.0)); } if let (Some((o, _)), Some(li)) = (list_level, li_at) { p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); @@ -310,7 +678,11 @@ impl<'r> Builder<'r> { } } if p.inlines.len() == 1 && p.text() == "\u{00A0}" { - // `

 

`: an empty paragraph on purpose. + // `

 

`: an empty paragraph on purpose, whose line is as high as the + // space's formatting makes it. + if let Some(Inline::Text(_, f)) = p.inlines.first() { + p.mark = Some(Box::new(f.clone())); + } p.inlines.clear(); self.para_cont = false; self.container().push(FBlock::Para(p)); @@ -482,6 +854,10 @@ impl<'r> Builder<'r> { let mut ft = FTable { rows, widths: Vec::new(), borderless }; if !ft.rows.is_empty() { ft.insert_covered(); + // Columns keep the widths their cells give when every column has one. + if ft.cols() <= MAX_TABLE_COLS { + ft.widths_from_cells(); + } self.container().push(FBlock::Table(ft)); } } @@ -625,13 +1001,15 @@ impl<'r> Builder<'r> { true } - fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind) -> ElementStyle { + /// `css`: the element's declarations in cascade order ([`Builder::css_of`]). + fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind, css: &str) -> ElementStyle { let mut fmt = self.fmt(); let mut align = None; let mut preserve = self.preserve(); let mut page_break = false; let mut page_break_after = false; match name { + // A header cell is bold, as in browsers and Word, unless its own CSS says otherwise. "b" | "strong" | "th" => fmt.bold = true, "i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true, "u" | "ins" => fmt.underline = true, @@ -662,9 +1040,16 @@ impl<'r> Builder<'r> { if name == "center" { align = Some(Align::Center); } - if let Some(style) = attr(attrs, "style") { - apply_style(style, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after); + if self.page + && let ElKind::Heading(n) = kind + { + // A page's headings are bold, black (unless they inherit a colour) and sized as + // browsers show them. + fmt.bold = true; + fmt.color.get_or_insert(wordcraft_doc::Rgb(0, 0, 0)); + fmt.size = PAGE_HEADING_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied(); } + apply_style(css, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after); if kind == ElKind::Pre { preserve = true; } @@ -703,9 +1088,10 @@ impl<'r> Builder<'r> { if kind != ElKind::Inline { self.flush(); } - let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind); + let css = self.css_of(name, attrs); + let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind, &css); match kind { - ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, true) }), + ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, &css, true) }), ElKind::Tr => { if let Some(t) = self.tables.last_mut() && let Some(r) = t.row.take() @@ -716,7 +1102,7 @@ impl<'r> Builder<'r> { ElKind::Cell => { let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none(); if let Some(t) = self.tables.last_mut() - && declares_border(attrs, false) + && declares_border(attrs, &css, false) { t.bordered = true; } @@ -728,6 +1114,7 @@ impl<'r> Builder<'r> { t.row = Some(Vec::new()); } let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63)); + let colspan = num("colspan").unwrap_or(1); let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background); let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() { "top" => Some(VAlign::Top), @@ -735,36 +1122,35 @@ impl<'r> Builder<'r> { "bottom" => Some(VAlign::Bottom), _ => None, }); - if let Some(st) = attr(attrs, "style") { - for decl in st.split(';') { - if let Some((k, v)) = decl.split_once(':') - && k.trim().eq_ignore_ascii_case("vertical-align") - { - match v.trim().to_ascii_lowercase().as_str() { - "top" => valign = Some(VAlign::Top), - "middle" | "center" => valign = Some(VAlign::Center), - "bottom" => valign = Some(VAlign::Bottom), - _ => {} - } - } + for (_, v) in decls(&css).filter(|(k, _)| k == "vertical-align") { + match v.to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} } } self.cells.push(Cell { blocks: Vec::new(), - colspan: num("colspan").unwrap_or(1), + colspan, rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1), covered: false, header: name == "th", shading, - borders: None, + borders: cell_borders(&css), valign, + width: if colspan == 1 { cell_width(&css, attrs) } else { None }, }); fmt.background = None; self.containers.push(Vec::new()); } _ => {} } - self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false }); + // The page's own margins (`body`, `html`) aren't its paragraphs'. + let spacing = + if (kind != ElKind::Inline || self.page) && !matches!(name, "body" | "html") { block_spacing(&css) } else { Spacing::default() }; + let auto_margins = self.page && (name == "p" || matches!(kind, ElKind::Heading(_))); + self.stack.push(El { name: name.to_string(), kind, fmt, spacing, auto_margins, align, preserve, page_break, page_break_after, used: false }); } pub(super) fn end(&mut self, name: &str) { diff --git a/crates/formats/src/html/css.rs b/crates/formats/src/html/css.rs index 6cb2a8c7b..0fcf54227 100644 --- a/crates/formats/src/html/css.rs +++ b/crates/formats/src/html/css.rs @@ -1,3 +1,5 @@ +use super::builder::decls; +use super::import::PAGE_FONT; use super::{Align, Fmt, model, parse_color}; /// A CSS length (or font-size keyword) in points; `None` when it is not one. @@ -33,7 +35,7 @@ pub fn css_len_pt(v: &str) -> Option { } } -fn strip_important(v: &str) -> &str { +pub(super) fn strip_important(v: &str) -> &str { let s = v.trim(); if let Some(pos) = s.rfind('!') { let after = s[pos + 1..].trim(); @@ -53,10 +55,7 @@ pub(super) fn apply_style( page_break: &mut bool, page_break_after: &mut bool, ) { - for decl in css.split(';').take(64) { - let Some((k, v)) = decl.split_once(':') else { continue }; - let k = k.trim().to_ascii_lowercase(); - let v = strip_important(v); + for (k, v) in decls(css) { let lv = v.to_ascii_lowercase(); match k.as_str() { "font-weight" => f.bold = lv.starts_with("bold") || lv.parse::().is_ok_and(|w| w >= 600), @@ -94,6 +93,14 @@ pub(super) fn apply_style( f.code = true; } else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") { f.font = model::kept_font(&fam).or(f.font.take()); + } else if f.font.is_some() { + // A generic family replaces a face the text has (a page's Times New Roman); + // text without one keeps the document's font. + match fam.to_ascii_lowercase().as_str() { + "serif" => f.font = Some(PAGE_FONT.into()), + "sans-serif" => f.font = Some("Arial".into()), + _ => {} + } } } "vertical-align" => { diff --git a/crates/formats/src/html/entity.rs b/crates/formats/src/html/entity.rs index 62b0fb103..09b017351 100644 --- a/crates/formats/src/html/entity.rs +++ b/crates/formats/src/html/entity.rs @@ -274,17 +274,39 @@ fn sniff_charset(head: &str) -> Option<&str> { /// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback. pub fn decode(b: &[u8]) -> String { + decode_as(b, None) +} + +/// [`decode`] in the `charset` its container states (a MIME part's, a content type's), when it +/// states one. Single-byte Windows code pages (`windows-1250`…, `iso-8859-1`, `us-ascii` as 1252) +/// are decoded as such, other labels by their WHATWG decoders. +pub fn decode_as(b: &[u8], charset: Option<&str>) -> String { if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) { return crate::txt::decode(b); } let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase(); - if let Some(label) = sniff_charset(&head) - && let Some(s) = crate::txt::decode_as(b, label) - { - return s; + let named = charset.map(str::to_ascii_lowercase).or_else(|| sniff_charset(&head).map(str::to_string)); + let code_page = match named.as_deref().map(str::trim) { + Some("iso-8859-1" | "latin1" | "us-ascii") => Some(1252), + Some(cs) => cs + .strip_prefix("windows-") + .or_else(|| cs.strip_prefix("cp")) + .and_then(|n| n.parse::().ok()) + .filter(|cp| wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(cp)), + None => None, + }; + // Other labels (ISO 8859, KOI8, Shift_JIS, GBK, Big5, EUC-KR…) by their WHATWG decoders; a + // UTF-16 one without a BOM means UTF-8, as in browsers. + let other = named + .as_deref() + .and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes())) + .filter(|e| ![encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE].contains(e)); + match (code_page, other) { + (Some(cp), _) => b.iter().map(|&c| wordcraft_doc::encoding::windows_byte(cp, c)).collect(), + (None, Some(enc)) => enc.decode_without_bom_handling(b).0.into_owned(), + (None, None) => match std::str::from_utf8(b) { + Ok(s) => s.to_string(), + Err(_) => crate::txt::decode(b), + }, } - if let Ok(s) = std::str::from_utf8(b) { - return s.to_string(); - } - crate::txt::decode(b) } diff --git a/crates/formats/src/html/import.rs b/crates/formats/src/html/import.rs index 2d6233baf..9e726af30 100644 --- a/crates/formats/src/html/import.rs +++ b/crates/formats/src/html/import.rs @@ -1,4 +1,4 @@ -use super::builder::{Builder, ImageLoader}; +use super::builder::{Builder, ImageLoader, parse_sheet}; use super::lexer::{Lexer, Tok}; use super::math_ruby::{read_math, read_ruby}; use super::{Document, Flow, Inline, model}; @@ -10,6 +10,10 @@ pub fn parse(s: &str) -> Flow { /// [`parse`], loading pictures that aren't `data:` URIs through `images`. pub fn parse_with(s: &str, images: ImageLoader) -> Flow { + parse_in(s, images, false) +} + +pub(super) fn parse_in(s: &str, images: ImageLoader, page: bool) -> Flow { // HTML normalizes input newlines before tokenization, not after decoding character references. let normalized = if s.contains('\r') { let mut out = String::with_capacity(s.len()); @@ -23,8 +27,10 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { } else { std::borrow::Cow::Borrowed(s) }; - let mut lx = Lexer { s: &normalized, pos: 0 }; - let mut b = Builder::new(images); + // The lexer's offsets (a style element's text is cut out by them) are into the normalized text. + let s: &str = &normalized; + let mut lx = Lexer { s, pos: 0 }; + let mut b = Builder::new(images, page); let mut guard = 0usize; while let Some(t) = lx.next() { guard += 1; @@ -52,7 +58,11 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { continue; } if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close { - lx.skip_past_end_tag(&name); + let from = lx.pos; + let to = lx.skip_past_end_tag(&name); + if name == "style" { + parse_sheet(s.get(from..to).unwrap_or(""), &mut b.sheet); + } continue; } b.start(&name, &attrs, self_close); @@ -91,9 +101,25 @@ pub fn import_with(s: &str, images: ImageLoader) -> Document { model::to_doc(&parse_with(s, images)) } -/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page +/// A web page opened as a document, as Word opens one (a file, or a document's HTML +/// `w:altChunk`): its text is Times New Roman 12 pt on single lines, headings bold and black at +/// the sizes browsers give them, and a `p` or heading whose CSS leaves a margin out (or writes it +/// invalidly) gets HTML's automatic spacing there; a margin a span states replaces the one its +/// paragraph states (both as Word's import does). [`import`] reads a pasted fragment, which takes +/// the document's own formatting instead (but its CSS still applies). +pub fn import_page(s: &str, images: ImageLoader) -> Document { + model::begin_import(); + model::to_doc(&parse_in(s, images, true)) +} + +/// [`import_page`] onto `page` (its size and margins), which tables size to: HTML gives no page /// size, so the caller picks it (the region's paper, #1381). pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document { model::begin_import(); - model::to_doc_in(&parse_with(s, images), page.clone()) + model::to_doc_in(&parse_in(s, images, true), page.clone()) } + +/// The font of a web page's text when its CSS names none. +pub const PAGE_FONT: &str = "Times New Roman"; +/// Heading sizes on a web page, `h1`–`h6`, points. +pub(super) const PAGE_HEADING_SIZES: [f32; 6] = [24.0, 18.0, 13.5, 12.0, 10.0, 7.5]; diff --git a/crates/formats/src/html/lexer.rs b/crates/formats/src/html/lexer.rs index 8b54bc055..a550f54ba 100644 --- a/crates/formats/src/html/lexer.rs +++ b/crates/formats/src/html/lexer.rs @@ -34,7 +34,9 @@ impl Lexer<'_> { } /// Skip past the next matching end tag `` (ASCII case-insensitive), or to the end. - pub(super) fn skip_past_end_tag(&mut self, tag: &str) { + /// Skip past the end tag of raw text element `tag`; returns where that end tag starts (the + /// end of the input when there is none). + pub(super) fn skip_past_end_tag(&mut self, tag: &str) -> usize { let prefix = format!(" { let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p)); let Some(k) = found else { self.pos = self.s.len(); - return; + return self.pos; }; let after_tag = k + p.len(); let next_byte = hay.get(after_tag); if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') { + let at = self.pos + k; self.pos += after_tag; self.skip_past(">"); - return; + return at; } self.pos += k + 2; } + self.s.len() } pub(super) fn next(&mut self) -> Option { diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index 2a111869e..179cf322f 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -7,6 +7,27 @@ fn numeric_entity_rejects_sign() { assert_eq!(unescape("aAAb"), "aAAb"); } +#[test] +fn important_declarations_win_the_cascade() { + let color = |html: &str| fmt_of(paras(&parse(html))[0]).color; + let red = Some(wordcraft_doc::Rgb(255, 0, 0)); + let blue = Some(wordcraft_doc::Rgb(0, 0, 255)); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); +} + +#[test] +fn a_declared_charset_decodes_the_page() { + // ISO 8859-2 (Central European): 0xA9 Š, 0xB9 š; named by the caller or the page. + assert_eq!(decode_as(&[0xA9, b' ', 0xB9], Some("iso-8859-2")), "Š š"); + assert!(decode_as(b"

\xA9

", None).contains("

Š

")); + assert_eq!(decode_as(&[0x8A], Some("windows-1252")), "Š"); + // A page that says UTF-16 without a BOM is read as UTF-8, as browsers do. + assert_eq!(decode_as("é".as_bytes(), Some("utf-16")), "é"); +} + fn texts(f: &Flow) -> Vec { f.blocks .iter() @@ -33,6 +54,9 @@ fn borderless_tables_get_no_grid_style() { assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + // The last declaration decides: a `style` attribute's over the style sheet's. + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + assert_eq!(table_style("
a
").as_deref(), Some("")); } #[test] @@ -522,3 +546,171 @@ fn empty_comments_do_not_swallow_the_rest() { assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}"); } } + +fn paras(f: &Flow) -> Vec<&Para> { + f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect() +} +fn fmt_of(p: &Para) -> Fmt { + p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap_or_default() +} + +#[test] +fn style_sheet_rules_apply_in_cascade_order() { + let f = parse( + "\ +

plain

big

inline wins

digits

", + ); + let ps = paras(&f); + let (plain, big, inline, digits) = (fmt_of(ps[0]), fmt_of(ps[1]), fmt_of(ps[2]), fmt_of(ps[3])); + assert_eq!((plain.color, plain.size, plain.bold, plain.italic), (Some(wordcraft_doc::Rgb(0, 255, 0)), Some(10.0), false, false)); + assert_eq!((big.size, big.bold), (Some(20.0), true)); + assert_eq!(inline.size, Some(8.0)); + assert_eq!(ps[3].align, Some(Align::Center)); + assert_eq!(digits.size, Some(10.0)); +} + +#[test] +fn paragraph_css_states_spacing_indents_and_line_height() { + let f = parse( + "\ +

a

b

c

", + ); + let ps = paras(&f); + assert_eq!( + ps[0].spacing, + Spacing { + before: Some(6.0), + after: Some(3.0), + left: Some(72.0), + right: Some(72.0), + first: Some(-18.0), + line: Some(LineSpacing::Multiple(1.5)), + ..Spacing::default() + } + ); + // Values that don't parse (a comma decimal, `auto`) state nothing. + assert_eq!(ps[1].spacing, Spacing { line: Some(LineSpacing::Exactly(20.0)), ..Spacing::default() }); + assert_eq!(ps[2].spacing, Spacing::default()); +} + +#[test] +fn a_page_opens_as_word_opens_one() { + let html = "\ +

first

Head

kept

own

bare

 

"; + let f = parse_in(html, &|_| None, true); + let ps = paras(&f); + // Times New Roman 12 pt on single lines; headings bold and black at a browser's sizes. + let first = fmt_of(ps[0]); + assert_eq!((first.font.as_deref(), first.size), (Some(PAGE_FONT), Some(12.0))); + assert_eq!(ps[0].spacing.line, Some(LineSpacing::Multiple(1.0))); + let cell = parse_in("
cell
", &|_| None, true); + let FBlock::Table(t) = &cell.blocks[0] else { panic!() }; + let FBlock::Para(cp) = &t.rows[0][0].blocks[0] else { panic!() }; + assert_eq!(cp.spacing.line, Some(LineSpacing::Multiple(1.0))); + let head = fmt_of(ps[1]); + assert_eq!((head.bold, head.size, head.color), (true, Some(24.0), Some(wordcraft_doc::Rgb(0, 0, 0)))); + // A colour the heading inherits stays. + // A generic family names a face on a page; pasted text keeps the document's font. + let generic = parse_in("

a

b

", &|_| None, true); + let fonts: Vec> = paras(&generic).iter().map(|p| fmt_of(p).font.map(|f| f.to_string())).collect(); + assert_eq!(fonts, [Some("Arial".to_string()), Some(PAGE_FONT.to_string())]); + assert_eq!(fmt_of(paras(&parse("

a

"))[0]).font, None); + let inherited = parse_in("

Head

", &|_| None, true); + assert_eq!(fmt_of(paras(&inherited)[0]).color, Some(wordcraft_doc::Rgb(0, 0, 0x80))); + // Automatic spacing where a p or heading leaves its margins out (layout drops it at the + // top of the page and of a cell). + let auto = |p: &Para| (p.spacing.before_auto, p.spacing.after_auto, p.spacing.before, p.spacing.after); + assert_eq!(auto(ps[0]), (true, true, None, None)); + assert_eq!(auto(ps[1]), (true, true, None, None)); + // A span's margin replaces the one its paragraph states; the one it leaves out stays automatic. + assert_eq!(auto(ps[2]), (true, false, None, Some(0.0))); + assert_eq!(auto(ps[3]), (true, false, None, Some(9.75))); + // Other blocks get no automatic spacing. + assert_eq!(auto(ps[4]), (false, false, None, None)); + // The document says so, and the empty paragraph's mark keeps its size. + let d = model::to_doc(&f); + let props = |i: usize| match &*d.body[i] { + wordcraft_doc::Block::Para(p) => p.clone(), + b => panic!("{b:?}"), + }; + assert_eq!((props(0).props.space_before_auto, props(0).props.space_after_auto), (Some(true), Some(true))); + assert_eq!((props(2).props.space_after_auto, props(2).props.space_after), (None, Some(0.0))); + assert_eq!(props(5).mark.size, Some(12.0)); + // `

 

` keeps the size of its space for its line. + assert!(ps[5].inlines.is_empty()); + assert_eq!(ps[5].mark.as_ref().and_then(|m| m.size), Some(12.0)); + // A fragment (a paste) keeps the document's own formatting. + let pasted = parse(html); + assert_eq!(fmt_of(paras(&pasted)[0]).font, None); + assert_eq!(paras(&pasted)[0].spacing, Spacing::default()); +} + +#[test] +fn cells_give_their_columns_widths() { + let f = parse( + "
t
1ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!("{:?}", f.blocks) }; + assert_eq!(t.widths, [31.5, 150.0, 144.0]); + // A column without a width leaves them all to the layout. + let f = parse("
ab
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert!(t.widths.is_empty()); + // A cell beside a cell spanning rows from above gives its own column its width. + let f = + parse("
ab
c
de
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.widths, [75.0, 150.0]); +} + +#[test] +fn cells_take_their_css_borders() { + use wordcraft_doc::props::{Border, BorderStyle}; + let f = parse( + "
ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + // The style sheet's other cell properties apply too. + assert_eq!(t.rows[0][0].valign, Some(wordcraft_doc::props::VAlign::Bottom)); + let b = t.rows[0][0].borders.expect("borders"); + assert_eq!(b.top, Some(Border::single(0.75))); + assert_eq!(b.bottom, Some(Border { style: BorderStyle::Double, width: 2.0, color: Some(wordcraft_doc::Rgb(255, 0, 0)), space: 0.0 })); + assert_eq!(b.left.map(|l| l.style), Some(BorderStyle::None)); + // A border of no width is none (OOXML has no zero-width line). + assert_eq!(b.right.map(|r| r.style), Some(BorderStyle::None)); + assert_eq!(t.rows[0][1].borders, None); +} + +#[test] +fn style_sheet_edge_cases() { + let f = parse_in( + "\ +

a

bare

b

", + &|_| None, + true, + ); + let ps = paras(&f); + // A rule right after statement at-rules still applies. + assert_eq!(fmt_of(ps[0]).color, Some(wordcraft_doc::Rgb(0, 0, 255))); + // The body's margins aren't the paragraphs' indents. + assert_eq!((ps[1].spacing.left, ps[1].spacing.before), (None, None)); + // Bare numbers, percentages and keywords state no margin: automatic spacing stays. + assert_eq!((ps[2].spacing.before_auto, ps[2].spacing.left), (true, None)); + // The `style` attribute wins even over a sheet with more declarations than are read. + let many = (0..600).map(|i| format!("x-{i}: 1")).collect::>().join(";"); + let f = parse(&format!("

c

")); + assert_eq!(fmt_of(paras(&f)[0]).color, Some(wordcraft_doc::Rgb(0, 255, 0))); +} + +#[test] +fn hostile_style_sheets_never_panic() { + // One declaration block shared by many selectors isn't copied for each. + let selectors = vec!["p"; 20_000].join(","); + let huge = format!("

x

", "x: y;".repeat(200_000)); + let _ = parse_in(&huge, &|_| None, true); + let many = ".a { color: red } ".repeat(20_000); + for css in ["{", "}", "p {", "@media { p { x } ", "p { color: red", "/* open", ".{}", "p..a{}", "\u{0}.é{x:y}", many.as_str()] { + let _ = parse_in(&format!("

x

y"), &|_| None, true); + } +} diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 8af0d2a50..2e293bf8c 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -1,6 +1,7 @@ //! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset -//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only, -//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.xls/.ods). +//! plus GFM tables and strikethrough), HTML (and MHT web archives, read-only), RTF, OpenDocument +//! Text and LaTeX — and, read-only, spreadsheet cells for mail-merge recipient lists ([`sheet`]: +//! .xlsx/.xlsm/.xls/.ods). //! //! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list //! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and @@ -14,6 +15,7 @@ pub mod html; pub mod latex; pub mod markdown; pub mod mathml; +pub mod mht; pub mod model; pub mod odt; pub mod rtf; @@ -23,7 +25,7 @@ pub mod txt; use wordcraft_doc::Document; /// Extensions this crate handles. -pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "tex", "latex", "ltx"]; +pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "mht", "mhtml", "rtf", "odt", "tex", "latex", "ltx"]; /// Import `bytes` in the format named by `ext` (no dot, any case). `None` = not our format. pub fn import(ext: &str, bytes: &[u8]) -> Option> { @@ -31,7 +33,8 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { let r = match ext.as_str() { "txt" | "text" => Ok(txt::import(bytes)), "md" | "markdown" => Ok(markdown::import(&txt::decode(bytes))), - "html" | "htm" | "xhtml" => Ok(html::import(&html::decode(bytes))), + "html" | "htm" | "xhtml" => Ok(html::import_page(&html::decode(bytes), &|_| None)), + "mht" | "mhtml" => Ok(mht::import(bytes)), "rtf" => rtf::import(bytes), "odt" => odt::import(bytes), "tex" | "latex" | "ltx" => Ok(latex::import(bytes)), @@ -44,7 +47,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { } /// [`import`] onto `page` (its size and margins) for the formats whose files give no page size: -/// plain text, Markdown and HTML start on it, and their tables size to it (#1381). The other +/// plain text, Markdown, HTML and MHT start on it, and their tables size to it (#1381). The other /// formats keep the page their file gives. pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { let ext = ext.trim_start_matches('.').to_ascii_lowercase(); @@ -52,6 +55,7 @@ pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> "txt" | "text" => txt::import_on(bytes, page), "md" | "markdown" => markdown::import_on(&txt::decode(bytes), page), "html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page), + "mht" | "mhtml" => mht::import_on(bytes, page), _ => return import(&ext, bytes), }; doc.ensure_nonempty(); diff --git a/crates/formats/src/mht.rs b/crates/formats/src/mht.rs new file mode 100644 index 000000000..c21c98d9a --- /dev/null +++ b/crates/formats/src/mht.rs @@ -0,0 +1,444 @@ +//! MHT / MHTML (Single File Web Page): a web page and its pictures in one MIME message (RFC 2557 +//! `multipart/related`, RFC 2045 transfer encodings). The page is the part the message's `start` +//! parameter names, else its first HTML part; it is read by [`crate::html`], with `img` sources +//! found among the other parts by `Content-Location` (also relative to the page's) or `cid:` +//! `Content-ID`, and the page opens as Word opens one ([`crate::html::import_page`]). Word keeps +//! HTML `w:altChunk`s in this form too. + +use std::collections::HashMap; +use std::sync::Arc; + +use wordcraft_doc::Document; + +/// Most parts read from one message (nested multiparts included). +const MAX_PARTS: usize = 10_000; +/// Deepest `multipart` nesting followed. +const MAX_NESTING: usize = 8; +/// Longest header block looked at for one part. +const MAX_HEADER: usize = 64 * 1024; +/// Longest boundary (RFC 2046 §5.1.1). +const MAX_BOUNDARY: usize = 70; +/// Picture bytes one page may load, counting every `img` that names a part. +const PICTURE_BUDGET: usize = 1 << 30; + +/// One leaf part of the message: its headers (names lower-cased) and decoded body. +struct Part { + content_type: String, + location: Option, + id: Option, + /// Shared by every `img` that names it. + body: Arc>, +} + +impl Part { + fn mime(&self) -> String { + self.content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase() + } +} + +/// Does `bytes` look like a MIME message (an `.mht` file)? +pub fn is_mht(bytes: &[u8]) -> bool { + let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); + head.starts_with("mime-version:") || head.starts_with("content-type: multipart/") || head.contains("\ncontent-type: multipart/related") +} + +/// Import an MHT file. A message without an HTML part reads as its text (an empty document +/// when there's none). +pub fn import(bytes: &[u8]) -> Document { + import_on(bytes, &wordcraft_doc::SectionProps::default()) +} + +/// [`import`] onto `page`: like HTML, an MHT file gives no page size (#1381). +pub fn import_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Document { + let mut parts = Vec::new(); + let (headers, body) = split_headers(bytes); + let start = param(header(&headers, "content-type").unwrap_or(""), "start").map(|s| s.trim_matches(['<', '>']).to_string()); + collect(&headers, body, &mut parts, 0); + let root = start + .as_deref() + .and_then(|s| parts.iter().position(|p| p.id.as_deref() == Some(s))) + .or_else(|| parts.iter().position(|p| matches!(p.mime().as_str(), "text/html" | "application/xhtml+xml"))); + let Some(root) = root.and_then(|i| parts.get(i)) else { + let text = parts.iter().find(|p| p.mime() == "text/plain").map(|p| p.body.as_slice()).unwrap_or(&[]); + return crate::txt::import_on(text, page); + }; + let html = crate::html::decode_as(&root.body, param(&root.content_type, "charset")); + let base = root.location.as_deref().unwrap_or(""); + // Parts by `Content-Location` (lower case) and `Content-ID`; the first of each wins. + let mut by_location = HashMap::new(); + let mut by_id = HashMap::new(); + for p in &parts { + if let Some(l) = &p.location { + by_location.entry(l.to_ascii_lowercase()).or_insert(p); + } + if let Some(i) = &p.id { + by_id.entry(i.as_str()).or_insert(p); + } + } + let left = std::cell::Cell::new(PICTURE_BUDGET); + let loader = |src: &str| -> Option>> { + let src = src.trim(); + let part = match src.get(..4).filter(|s| s.eq_ignore_ascii_case("cid:")).and(src.get(4..)) { + Some(cid) => by_id.get(cid), + // By its location as written, or resolved against the page's. + None => by_location.get(&src.to_ascii_lowercase()).or_else(|| by_location.get(&resolve(base, src).to_ascii_lowercase())), + }?; + left.set(left.get().checked_sub(part.body.len())?); + Some(part.body.clone()) + }; + crate::html::import_on(&html, &loader, page) +} + +/// `reference` resolved against the page's location `base`, as RFC 2557 §5 asks (RFC 3986 +/// §5.2.2): a reference with a scheme stays as it is, `//host/…` takes the base's scheme, `/…` +/// its scheme and host, an empty path its path and query, anything else is merged with its path +/// (§5.2.3); `.` and `..` segments go (§5.2.4) and fragments are dropped. Nothing is fetched. +fn resolve(base: &str, reference: &str) -> String { + let (b, r) = (UriParts::of(base), UriParts::of(reference)); + let t = if r.scheme.is_some() { + UriParts { path: remove_dots(&r.path), ..r } + } else if r.authority.is_some() { + UriParts { scheme: b.scheme, path: remove_dots(&r.path), ..r } + } else if r.path.is_empty() { + UriParts { query: r.query.or(b.query), ..b } + } else if r.path.starts_with('/') { + UriParts { path: remove_dots(&r.path), query: r.query, ..b } + } else { + let merged = if b.authority.is_some() && b.path.is_empty() { + format!("/{}", r.path) + } else { + format!("{}{}", b.path.rfind('/').and_then(|i| b.path.get(..=i)).unwrap_or(""), r.path) + }; + UriParts { path: remove_dots(&merged), query: r.query, ..b } + }; + t.to_string() +} + +/// A URI's parts (RFC 3986 §3): `scheme:`, `//authority`, path and `?query`; the fragment left out. +struct UriParts<'a> { + scheme: Option<&'a str>, + authority: Option<&'a str>, + path: String, + query: Option<&'a str>, +} + +impl<'a> UriParts<'a> { + fn of(uri: &'a str) -> Self { + let uri = uri.split('#').next().unwrap_or(""); + // A one-letter scheme is a Windows drive (`C:/…`), not a scheme. + let scheme = uri.split_once(':').map(|(k, _)| k).filter(|k| { + k.len() > 1 && k.starts_with(|c: char| c.is_ascii_alphabetic()) && k.chars().all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + }); + let rest = scheme.and_then(|k| uri.get(k.len() + 1..)).unwrap_or(uri); + let (authority, rest) = match rest.strip_prefix("//") { + Some(r) => { + let end = r.find(['/', '?']).unwrap_or(r.len()); + (r.get(..end), r.get(end..).unwrap_or("")) + } + None => (None, rest), + }; + let (path, query) = match rest.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (rest, None), + }; + UriParts { scheme, authority, path: path.to_string(), query } + } +} + +impl std::fmt::Display for UriParts<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(s) = self.scheme { + write!(f, "{s}:")?; + } + if let Some(a) = self.authority { + write!(f, "//{a}")?; + } + f.write_str(&self.path)?; + if let Some(q) = self.query { + write!(f, "?{q}")?; + } + Ok(()) + } +} + +/// `path` without its `.` and `..` segments (RFC 3986 §5.2.4); `..` stops at the root. +fn remove_dots(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + let segments: Vec<&str> = path.split('/').collect(); + for (i, seg) in segments.iter().enumerate() { + match *seg { + "." => {} + ".." => { + if out.len() > 1 || out.first().is_some_and(|f| !f.is_empty()) { + out.pop(); + } + } + s => out.push(s), + } + if i + 1 == segments.len() && matches!(*seg, "." | "..") { + out.push(""); + } + } + out.join("/") +} + +/// Split a part into its unfolded header lines and its body (after the first empty line). +fn split_headers(b: &[u8]) -> (Vec<(String, String)>, &[u8]) { + // Whichever blank line comes first ends the headers. + let blank = [find_bytes(b, b"\r\n\r\n").map(|i| (i, 4)), find_bytes(b, b"\n\n").map(|i| (i, 2))].into_iter().flatten().min(); + let (head, body) = match blank { + Some((i, n)) => (b.get(..i).unwrap_or(&[]), b.get(i + n..).unwrap_or(&[])), + // Headers only, or a body without them. + None if b.first().is_some_and(|c| c.is_ascii_alphabetic()) && find_bytes(b, b":").is_some() => (b, &[][..]), + None => (&[][..], b), + }; + let head = String::from_utf8_lossy(head.get(..head.len().min(MAX_HEADER)).unwrap_or(head)); + let mut headers: Vec<(String, String)> = Vec::new(); + for line in head.lines() { + if line.starts_with([' ', '\t']) { + if let Some((_, v)) = headers.last_mut() { + v.push(' '); + v.push_str(line.trim()); + } + } else if let Some((k, v)) = line.split_once(':') { + headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string())); + } + } + (headers, body) +} + +fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> { + h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) +} + +/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive. +fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> { + value.split(';').skip(1).find_map(|p| { + let (k, v) = p.split_once('=')?; + k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"')) + }) +} + +/// Gather the leaf parts of a (possibly multipart) entity. +fn collect(headers: &[(String, String)], body: &[u8], out: &mut Vec, depth: usize) { + if out.len() >= MAX_PARTS { + return; + } + let ct = header(headers, "content-type").unwrap_or("text/plain").to_string(); + if ct.trim_start().to_ascii_lowercase().starts_with("multipart/") { + let Some(boundary) = param(&ct, "boundary").filter(|b| !b.is_empty() && b.len() <= MAX_BOUNDARY) else { return }; + if depth >= MAX_NESTING { + return; + } + for chunk in multipart_bodies(body, boundary.as_bytes()) { + let (h, b) = split_headers(chunk); + collect(&h, b, out, depth + 1); + if out.len() >= MAX_PARTS { + return; + } + } + return; + } + let enc = header(headers, "content-transfer-encoding").unwrap_or("").trim().to_ascii_lowercase(); + let body = match enc.as_str() { + "quoted-printable" => quoted_printable(body), + "base64" => crate::model::base64_decode(&String::from_utf8_lossy(body)).unwrap_or_default(), + _ => body.to_vec(), + }; + let clean = |s: &str| s.trim().trim_matches(['<', '>']).to_string(); + out.push(Part { + content_type: ct, + location: header(headers, "content-location").map(clean), + id: header(headers, "content-id").map(clean), + body: Arc::new(body), + }); +} + +/// The bodies between `--boundary` lines, up to the closing `--boundary--`. +fn multipart_bodies<'a>(body: &'a [u8], boundary: &[u8]) -> Vec<&'a [u8]> { + let mut delim = b"--".to_vec(); + delim.extend_from_slice(boundary); + let mut out = Vec::new(); + // Delimiters sit at line starts. + let mut starts = Vec::new(); + let mut i = 0; + while let Some(k) = body.get(i..).and_then(|rest| find_bytes(rest, &delim)) { + let at = i + k; + if at == 0 || body.get(at - 1) == Some(&b'\n') { + starts.push(at); + if starts.len() > MAX_PARTS { + break; + } + } + i = at + delim.len(); + } + for w in starts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + let Some(seg) = body.get(a + delim.len()..b) else { continue }; + if seg.starts_with(b"--") { + break; + } + // The rest of the delimiter line, then the part; the line break before the next + // delimiter belongs to it. + let seg = match find_bytes(seg, b"\n") { + Some(n) => seg.get(n + 1..).unwrap_or(&[]), + None => &[], + }; + let seg = seg.strip_suffix(b"\r\n").or_else(|| seg.strip_suffix(b"\n")).unwrap_or(seg); + out.push(seg); + if out.len() >= MAX_PARTS { + break; + } + } + out +} + +fn find_bytes(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return None; + } + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Decode quoted-printable (RFC 2045 §6.7): `=XX` bytes and `=` soft line breaks; anything +/// malformed is kept as it stands. +fn quoted_printable(b: &[u8]) -> Vec { + let hex = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c != b'=' { + out.push(c); + i += 1; + continue; + } + match (b.get(i + 1).copied(), b.get(i + 2).copied()) { + (Some(b'\r'), Some(b'\n')) => i += 3, + (Some(b'\n'), _) => i += 2, + (Some(h), Some(l)) if let (Some(h), Some(l)) = (hex(h), hex(l)) => { + out.push((h << 4) | l); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::{Block, InlineObject}; + + const PNG_1X1: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + fn message(html_part: &str) -> String { + format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"----=_B\"\r\n\r\n\ + ------=_B\r\n{html_part}\r\n\ + ------=_B\r\nContent-Location: file:///C:/page_files/dot.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n\ + ------=_B--\r\n" + ) + } + + #[test] + fn reads_the_quoted_printable_page_and_its_pictures() { + let page = "Content-Location: file:///C:/page.htm\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

Dobar dan, gospo=C4=91o, ovo je =\r\njedan red.

"; + let d = import(message(page).as_bytes()); + let text = d.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.contains("Dobar dan, gospođo, ovo je jedan red."), "{text:?}"); + let pictures = + d.body.iter().filter(|b| matches!(&***b, Block::Para(p) if p.objects.iter().any(|o| matches!(o, InlineObject::Image { .. })))).count(); + assert_eq!(pictures, 1); + assert_eq!(d.media.len(), 1); + } + + #[test] + fn pictures_named_relative_to_the_page_resolve_as_uris() { + // The page in `reports/`, its picture in `images/` (RFC 2557 §5: relative URIs resolve + // against the page's Content-Location). + let m = |src: &str| { + format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\ + --b\r\nContent-Location: https://example.test/reports/page.html\r\nContent-Type: text/html\r\n\r\n

x

\r\n\ + --b\r\nContent-Location: https://example.test/images/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n" + ) + }; + for src in [ + "../images/logo.png", + "/images/logo.png", + "./../images/./logo.png", + "//example.test/images/logo.png", + "https://example.test/images/logo.png", + ] { + assert_eq!(import(m(src).as_bytes()).media.len(), 1, "{src}"); + } + // Nothing past the root: `..` there stays at the root. + assert_eq!(import(m("../../../images/logo.png").as_bytes()).media.len(), 1); + assert_eq!(import(m("images/logo.png").as_bytes()).media.len(), 0); + assert_eq!(resolve("https://h/a/b/c.html", "../d/./e.png#f"), "https://h/a/d/e.png"); + assert_eq!(resolve("file:///C:/x/page.htm", "page_files/dot.png"), "file:///C:/x/page_files/dot.png"); + assert_eq!(resolve("", "dot.png"), "dot.png"); + // A host with no path, and a base whose query holds slashes (RFC 3986 §5.2.3: the path + // merges, not the query; an authority with an empty path gets its `/`). + assert_eq!(resolve("https://example.test", "logo.png"), "https://example.test/logo.png"); + assert_eq!(resolve("https://example.test/page.html?redirect=/other/", "logo.png"), "https://example.test/logo.png"); + assert_eq!(resolve("https://h/a/p.html?q", "?r"), "https://h/a/p.html?r"); + assert_eq!(resolve("https://h/a/p.html?q", "x.png?v=1"), "https://h/a/x.png?v=1"); + assert_eq!(resolve("https://h/a/p.html", "mailto:x@y"), "mailto:x@y"); + for page in ["https://example.test", "https://example.test/page.html?redirect=/other/"] { + let m = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\ + --b\r\nContent-Location: {page}\r\nContent-Type: text/html\r\n\r\n

x

\r\n\ + --b\r\nContent-Location: https://example.test/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n" + ); + assert_eq!(import(m.as_bytes()).media.len(), 1, "{page}"); + } + } + + #[test] + fn the_part_charset_decides_the_text() { + let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n

=E8lanak

"; + let d = import(message(page).as_bytes()); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "članak"); + } + + #[test] + fn the_start_part_is_the_page_and_cid_names_pictures() { + let m = format!( + "MIME-Version: 1.0\nContent-Type: multipart/related; type=\"text/html\"; start=\"\"; boundary=b\n\n\ + --b\nContent-Type: text/html\n\n

Not this one

\n\ + --b\nContent-ID: \nContent-Type: text/html\n\n

This one

\n\ + --b\nContent-ID: \nContent-Transfer-Encoding: base64\nContent-Type: image/png\n\n{PNG_1X1}\n--b--\n" + ); + let d = import(m.as_bytes()); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "This one"); + assert_eq!(d.media.len(), 1); + // Without a page, a message reads as its text. + let d = import(b"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=b\n\n--b\nContent-Type: text/plain\n\nJust text\n--b--\n"); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Just text"); + assert!(is_mht(m.as_bytes()) && !is_mht(b"

x

")); + } + + #[test] + fn hostile_messages_never_panic() { + for m in [ + &b""[..], + b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"\"\r\n\r\n", + b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=x\r\n\r\n--x\r\n--x--", + b"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=x\n\n--x\nContent-Transfer-Encoding: quoted-printable\nContent-Type: text/html\n\n=\n=Z=4", + b"Content-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n!!!!", + ] { + let _ = import(m); + } + let mut deep = String::from("MIME-Version: 1.0\r\n"); + for i in 0..64 { + deep.push_str(&format!("Content-Type: multipart/related; boundary=b{i}\r\n\r\n--b{i}\r\n")); + } + let _ = import(deep.as_bytes()); + } +} diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 120cf9d37..0f77785f1 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -148,6 +148,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { mark_ins: None, mark_del: None, fmt_change: None, + mark: None, }; let in_code = kind == Kind::Code; let mut k = 0usize; @@ -179,6 +180,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { mark_ins: None, mark_del: None, fmt_change: None, + mark: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { @@ -320,6 +322,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable shading: c.props.shading, borders: c.props.borders, valign: c.props.valign, + width: None, }); } rows.push(cells); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 2f4fa1361..d61243213 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -100,6 +100,12 @@ fn para_props(p: &Para, list_num: Option) -> ParaProps { out.indent_left = sp.left; out.indent_right = sp.right; out.indent_first = sp.first; + if sp.before_auto { + out.space_before_auto = Some(true); + } + if sp.after_auto { + out.space_after_auto = Some(true); + } if p.page_break { out.page_break_before = Some(true); } @@ -177,6 +183,9 @@ impl<'a> Builder<'a> { let old = para_props(&c.old, old_num); out.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old); } + if let Some(f) = &p.mark { + out.mark = self.budget(char_props(f)); + } if p.keep_next { out.props.keep_next = Some(true); } diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 1b52b2e68..0747af578 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -220,6 +220,9 @@ pub struct Spacing { pub right: Option, /// First-line indent; negative = hanging. pub first: Option, + /// HTML's automatic space before / after (see `ParaProps::space_before_auto`). + pub before_auto: bool, + pub after_auto: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -244,6 +247,8 @@ pub struct Para { /// Tracked formatting change of the paragraph: the paragraph's formatting before it (kind, /// list, alignment, page break, spacing and borders of `old`; its inlines are unused). pub fmt_change: Option>>, + /// Formatting of an empty paragraph's mark (what sizes its line), when the source gives it. + pub mark: Option>, } impl Para { @@ -337,11 +342,14 @@ pub struct Cell { /// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style. pub borders: Option, pub valign: Option, + /// The width, points, the source gives a cell spanning one column (HTML): see + /// [`FTable::widths_from_cells`]. + pub width: Option, } impl Default for Cell { fn default() -> Self { - Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None } + Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None, width: None } } } @@ -372,6 +380,22 @@ impl FTable { pub fn cols(&self) -> usize { self.rows.iter().map(|r| r.iter().map(|c| c.colspan.clamp(1, 63) as usize).sum::()).max().unwrap_or(0) } + /// Grid column widths from the cells that give one (after [`Self::insert_covered`], so a + /// cell's column counts those spanned from rows above), when every column has one. + pub fn widths_from_cells(&mut self) { + let mut widths = vec![None; self.cols()]; + for row in &self.rows { + let mut g = 0; + for c in row { + if let (Some(w), Some(slot)) = (c.width, widths.get_mut(g)) { + slot.get_or_insert(w); + } + g += c.colspan.clamp(1, 63) as usize; + } + } + self.widths = widths.into_iter().collect::>>().unwrap_or_default(); + } + /// After an importer that gives `rowspan` but no placeholder cells (HTML): insert covered /// cells into the rows below each spanning cell. pub fn insert_covered(&mut self) { diff --git a/crates/formats/src/rtf/styles.rs b/crates/formats/src/rtf/styles.rs index 94c6ea489..a78a99a43 100644 --- a/crates/formats/src/rtf/styles.rs +++ b/crates/formats/src/rtf/styles.rs @@ -261,5 +261,13 @@ fn spacing(st: &St) -> model::Spacing { } else { Some(LineSpacing::AtLeast(pt(sl))) }; - model::Spacing { before: gap(st.sb), after: gap(st.sa), line, left: st.li.map(pt), right: st.ri.map(pt), first: st.fi.map(pt) } + model::Spacing { + before: gap(st.sb), + after: gap(st.sa), + line, + left: st.li.map(pt), + right: st.ri.map(pt), + first: st.fi.map(pt), + ..Default::default() + } } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 6bec025c8..c18a68a4a 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -39,7 +39,8 @@ pub type FileDialogAnswer = Result, String>; const MAX_FONTS_PER_PICK: usize = 2000; const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; -const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; +const DOCUMENTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; /// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change /// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fd2c19880..d9c9ab962 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-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; previously trivial: DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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; @@ -58,9 +58,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | -| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | -| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | +| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | +| `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | @@ -121,10 +121,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | | Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), `w:snapToGrid` on paragraphs, runs and styles | ✅ | ✅ | #391; run and character-style flags keep absent/true/false across repeated saves; inherited true remains explicit false when overridden. `w:webHidden` remains distinct from `w:vanish` and preserves absent/true/false through runs, styles and repeated saves; Print Layout still shows web-hidden text | +| Alternative format chunks (`w:altChunk`) | ✅ | — | Brought into the document where they stand when it opens, as Word does: MHT, HTML and XHTML as web pages, RTF and plain text through their readers, a nested WordprocessingML package by the docx reader (at most 4 deep, 256 chunks and 512 MB of chunk data a file). The chunk's pictures, lists, styles, notes, comments and tracked changes come along (the document's own styles win by id, unless the chunk asks to keep its source formatting, `w:matchSrc`: then its styles come along under new ids, carrying its own defaults); its section breaks don't. Saving writes the content as ordinary paragraphs, as Word does. In a Word XML (Flat OPC) file only WordprocessingML chunks come in | | Two lines in one and horizontal in vertical (`w:eastAsianLayout`, §17.3.2.10) | ✅ | ✅ | Every attribute (`w:id`, `w:combine`, `w:combineBrackets`, `w:vert`, `w:vertCompress`) read into the run's formatting and written back after `w:lang`, in runs and styles; values outside the schema are dropped (`crates/docx/tests/it/east_asian_layout.rs`). Not laid out yet: the text shows as ordinary text | | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | -| `w:altChunk`, sub-documents | ❌ | ❌ | | +| Sub-documents | ❌ | ❌ | | | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Failed zip entries count against the size limit, case variants of a part name are one part, embedded fonts and charts are written within budgets, and Flat OPC inlines kept XML only when it is one well-formed element (2026-10-22) | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | @@ -281,6 +282,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle | | 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) | diff --git a/docs/gaps.md b/docs/gaps.md index 735c988ab..09b1e598e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (#26: MHT opens and `w:altChunk` is brought in; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **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). @@ -90,7 +90,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | -| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | +| 26 | **Minor formats**: saving MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word); MHT opens and documents' `w:altChunk` content is brought in | Rare | 12–23 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -154,6 +154,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | From 868fffa6fe865dd535298cbc5ae619be4bf5a84a Mon Sep 17 00:00:00 2001 From: Naxvog <4963183+naxvog@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:02:56 +0200 Subject: [PATCH 16/20] Files: a Word chunk that asks to keep its source formatting keeps it (w:matchSrc) An alternative format chunk holding a Word document with w:matchSrc keeps its own styles: a style the document already has comes along under a new id, the chunk's document defaults (and Word's, where they say nothing) go into its base styles, and its unstyled paragraphs use its own Normal. Without w:matchSrc the document's styles win, as before. Adopting another document now also renumbers table styles and the revisions of table, row and cell property changes. A document whose chunk shares style names with it (Normal with an exact line height, an italic Subtitle) ran a page longer than in Word: the chunk's tables took the document's styles. It now keeps its own. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/edit.rs | 234 +++++++++++++++++++++++------ crates/doc/src/edit/tests.rs | 107 ++++++++++--- crates/doc/src/tests.rs | 19 +++ crates/docx/src/read/chunk.rs | 8 +- crates/docx/tests/it/alt_chunks.rs | 92 +++++++++++- 5 files changed, 384 insertions(+), 76 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index c485239e7..0d426937e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -389,25 +389,10 @@ fn restyle_block(b: &mut Block, from: &str, to: &Option, depth: usize) - } } -/// One past the highest permission range id in `blocks`. -fn perm_end<'a>(blocks: impl Iterator>) -> u32 { - let mut end = 0u32; - for b in blocks { - each_para(b, 0, &mut |p| { - for o in &p.objects { - if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o { - end = end.max(id.saturating_add(1)); - } - } - }); - } - end -} - -/// Text box story ids of a paragraph's shapes. -/// What [`Document::adopt`] renumbers in another document's content: media keys, lists, note -/// and comment stories, comment ids, text boxes (one it doesn't bring is dropped) and revisions -/// (after ours, at `rev`). +/// What [`Document::adopt_with`] renumbers in another document's content: media keys, lists, +/// note and comment stories, comment ids, text boxes (one it doesn't bring is dropped), revisions +/// (after ours, at `rev`; an id past the `revs` it brings is dropped) and styles (those kept under new ids; unstyled paragraphs take +/// `default_style` when it's given). struct Relink<'a> { media: &'a BTreeMap, nums: &'a BTreeMap, @@ -417,37 +402,97 @@ struct Relink<'a> { /// Added to permission range ids: ours come first. perms: u32, rev: u32, + revs: u32, + styles: &'a BTreeMap, + default_style: Option<&'a str>, } impl Relink<'_> { - // shortcut: tracked changes of table, row and cell properties keep their revision ids; renumber them too when tables from other documents carry revisions. fn block(&self, b: &mut Block) { - each_para_mut(b, 0, &mut |p| self.para(p)); + // Its own stack, like `each_para_mut`: tables however deeply nested are relinked (#1339). + let mut todo = vec![b]; + while let Some(b) = todo.pop() { + match b { + Block::Para(p) => self.para(p), + Block::Table(t) => { + self.style(&mut t.props.style); + self.shift_change(&mut t.props.fmt_change); + if let Some(c) = t.props.fmt_change.as_mut() { + self.style(&mut c.old.style); + } + for row in t.rows.iter_mut() { + self.shift_change(&mut row.props.fmt_change); + for cell in row.cells.iter_mut() { + self.shift_change(&mut cell.props.fmt_change); + todo.extend(cell.blocks.iter_mut().map(Arc::make_mut)); + } + } + } + } + } + } + + fn style(&self, id: &mut Option) { + if let Some(new) = id.as_ref().and_then(|i| self.styles.get(i)) { + *id = Some(new.clone()); + } + } + + /// A paragraph's style: unstyled ones take `default_style` when it's given. + fn para_style(&self, id: &mut Option) { + match (&*id, self.default_style) { + (None, Some(d)) => *id = Some(d.to_string()), + _ => self.style(id), + } + } + + fn num(&self, n: &mut Option) { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = self.nums.get(&n.num).copied().unwrap_or(0); + } + } + + /// Our id for the other document's revision `r`, if it has one. + fn rev_id(&self, r: u32) -> Option { + if r < self.revs { self.rev.checked_add(r) } else { None } + } + + fn shift_change(&self, c: &mut Option>>) { + match (c.as_ref().and_then(|c| self.rev_id(c.rev)), c.as_mut()) { + (Some(v), Some(x)) => x.rev = v, + _ => *c = None, + } } fn para(&self, p: &mut Paragraph) { - // A tracked change's old formatting names lists as the live one does. - let old = p.props.fmt_change.as_mut().and_then(|f| f.old.numbering.as_mut()); - for n in p.props.numbering.iter_mut().chain(old) { - if n.num != 0 { - n.num = self.nums.get(&n.num).copied().unwrap_or(0); - } + self.num(&mut p.props.numbering); + self.para_style(&mut p.props.style); + for r in &mut p.runs { + self.style(&mut r.props.style); } - let shift = |r: &mut u32| *r = r.saturating_add(self.rev); + self.style(&mut p.mark.style); for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) { - c.ins.iter_mut().chain(c.del.iter_mut()).for_each(shift); + c.ins = c.ins.and_then(|r| self.rev_id(r)); + c.del = c.del.and_then(|r| self.rev_id(r)); + self.shift_change(&mut c.fmt_change); + // A tracked change's old formatting names styles and lists as the live one does. if let Some(f) = c.fmt_change.as_mut() { - shift(&mut f.rev); + self.style(&mut f.old.style); } } + self.shift_change(&mut p.props.fmt_change); if let Some(f) = p.props.fmt_change.as_mut() { - shift(&mut f.rev); + self.para_style(&mut f.old.style); + self.num(&mut f.old.numbering); } - if let Some(f) = p.section.as_deref_mut().and_then(|s| s.fmt_change.as_mut()) { - shift(&mut f.rev); + if let Some(s) = p.section.as_deref_mut() { + self.shift_change(&mut s.fmt_change); } - if let Some(f) = p.props.num_change.as_mut() { - shift(&mut f.rev); + match (p.props.num_change.as_ref().and_then(|c| self.rev_id(c.rev)), p.props.num_change.as_mut()) { + (Some(v), Some(c)) => c.rev = v, + _ => p.props.num_change = None, } for o in &mut p.objects { self.object(o, 0); @@ -490,6 +535,22 @@ impl Relink<'_> { } } +/// One past the highest permission range id in `blocks`. +fn perm_end<'a>(blocks: impl Iterator>) -> u32 { + let mut end = 0u32; + for b in blocks { + each_para(b, 0, &mut |p| { + for o in &p.objects { + if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o { + end = end.max(id.saturating_add(1)); + } + } + }); + } + end +} + +/// Text box story ids of a paragraph's shapes. fn box_ids(p: &Paragraph) -> impl Iterator + '_ { p.objects.iter().flat_map(InlineObject::text_boxes) } @@ -971,12 +1032,46 @@ impl Document { /// revisions renumbered so they can't collide with ours; a style we already have by id stays /// ours), and its text boxes ride in [`Fragment::parts`]. pub fn adopt(&mut self, src: Document) -> Fragment { - self.adopt_in(src, &mut 0) + self.adopt_with(src, false) } - /// [`Self::adopt`], its permission range ids from at least `perms` on (and past ours); + /// [`Self::adopt`]; with `keep_styles` its content keeps its own formatting (Word's + /// `w:matchSrc`): a style whose id we already have comes along under a new id, and its + /// document defaults go into its base styles, which its unstyled paragraphs then use. + pub fn adopt_with(&mut self, src: Document, keep_styles: bool) -> Fragment { + self.adopt_in(src, keep_styles, &mut 0) + } + + /// [`Self::adopt_with`], its permission range ids from at least `perms` on (and past ours); /// `perms` moves past them. - fn adopt_in(&mut self, src: Document, perms: &mut u32) -> Fragment { + fn adopt_in(&mut self, mut src: Document, keep_styles: bool, perms: &mut u32) -> Fragment { + let mut renamed = BTreeMap::new(); + let mut default_style = None; + if keep_styles { + if src.styles.get("Normal").is_none() { + src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() }); + } + // Ids given out here are taken too: two styles never get the same new one. + let mut given = std::collections::HashSet::new(); + let room = src.styles.styles.len().saturating_mul(2).saturating_add(self.styles.styles.len()).saturating_add(1); + for st in &src.styles.styles { + if self.styles.get(&st.id).is_some() { + let fresh = (1..=room) + .map(|n| format!("{}{n}", st.id)) + .find(|id| self.styles.get(id).is_none() && src.styles.get(id).is_none() && !given.contains(id)); + if let Some(fresh) = fresh { + given.insert(fresh.clone()); + renamed.insert(st.id.clone(), fresh); + } + } + } + default_style = Some(renamed.get("Normal").cloned().unwrap_or_else(|| "Normal".into())); + } + let restyle = |id: &mut Option| { + if let Some(new) = id.as_ref().and_then(|i| renamed.get(i)) { + *id = Some(new.clone()); + } + }; let mut media = BTreeMap::new(); for (key, data) in &src.media { let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); @@ -987,7 +1082,9 @@ impl Document { for (i, a) in src.numbering.abstracts.iter().enumerate() { let id = next_abs.saturating_add(i as u32); abs_map.insert(a.id, id); - self.numbering.abstracts.push(crate::numbering::AbstractNum { id, ..a.clone() }); + let mut a = crate::numbering::AbstractNum { id, ..a.clone() }; + a.levels.iter_mut().for_each(|l| restyle(&mut l.style)); + self.numbering.abstracts.push(a); } let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); let mut nums = BTreeMap::new(); @@ -995,7 +1092,9 @@ impl Document { let id = next_num.saturating_add(i as u32); nums.insert(n.id, id); let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); - self.numbering.nums.push(crate::numbering::Num { id, abstract_id, ..n.clone() }); + let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() }; + n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style)); + self.numbering.nums.push(n); } let renumber = |n: &mut Option| { if let Some(n) = n.as_mut() @@ -1004,11 +1103,35 @@ impl Document { n.num = nums.get(&n.num).copied().unwrap_or(0); } }; + // What a style in the other document doesn't set comes from its defaults there, and + // Word's own (no space, single lines) where those don't say. + let mut para_defaults = src.styles.default_para.clone(); + para_defaults.space_before.get_or_insert(0.0); + para_defaults.space_after.get_or_insert(0.0); + para_defaults.line_spacing.get_or_insert(crate::props::LineSpacing::Multiple(1.0)); for st in &src.styles.styles { + let mut st = st.clone(); + // Our defaults stay ours. + st.default = false; + if keep_styles { + if let Some(new) = renamed.get(&st.id) { + st.name = format!("{} ({new})", st.name); + st.id = new.clone(); + } + restyle(&mut st.based_on); + restyle(&mut st.next); + restyle(&mut st.linked); + // A character style's runs get the defaults through their paragraph's style. + if st.based_on.is_none() && st.kind != crate::styles::StyleKind::Character { + let mut chr = src.styles.default_chr.clone(); + chr.overlay(&st.chr); + st.chr = chr; + let mut para = para_defaults.clone(); + para.overlay(&st.para); + st.para = para; + } + } if self.styles.get(&st.id).is_none() { - let mut st = st.clone(); - // Our defaults stay ours. - st.default = false; renumber(&mut st.para.numbering); self.styles.upsert(st); } @@ -1034,7 +1157,18 @@ impl Document { // Its permission ranges are numbered after ours, so no end of theirs closes one of ours. let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms); *perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter())))); - let link = Relink { media: &media, nums: &nums, notes: ¬es, comments: &comments, boxes: &boxes, perms: first_perm, rev }; + let link = Relink { + media: &media, + nums: &nums, + notes: ¬es, + comments: &comments, + boxes: &boxes, + perms: first_perm, + rev, + revs: u32::try_from(src.revisions.len()).unwrap_or(u32::MAX), + styles: &renamed, + default_style: default_style.as_deref(), + }; let mut parts = boxes.clone(); for part in parts.values_mut() { part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b))); @@ -1062,12 +1196,12 @@ impl Document { Fragment { blocks, parts } } - /// [`Self::adopt`] as blocks to place in a story directly (a reader building one): its text - /// boxes already have stories of their own here. `perms` is the first permission range id the - /// reader hasn't given out (ranges it holds aren't in this document yet); it moves past the - /// adopted ones. - pub fn adopt_blocks(&mut self, src: Document, perms: &mut u32) -> Vec { - let Fragment { mut blocks, parts } = self.adopt_in(src, perms); + /// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its + /// text boxes already have stories of their own here. `perms` is the first permission range + /// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past + /// the adopted ones. + pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec { + let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms); let mut budget = BoxBudget::new(); self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); blocks diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index 34cf563ee..eaebae22a 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -688,6 +688,8 @@ fn adopting_a_document_renumbers_what_it_refers_to() { let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 }); p.runs[0].props.del = Some(0); + // An insertion naming a revision it doesn't have. + p.runs[0].props.ins = Some(7); let props = p.props_of_char(0).clone(); p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap(); p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap(); @@ -701,6 +703,7 @@ fn adopting_a_document_renumbers_what_it_refers_to() { // Their deletion is their revision, after ours. let del = p.runs.iter().find_map(|r| r.props.del).unwrap(); assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete)); + assert!(p.runs.iter().all(|r| r.props.ins.is_none())); // Their note and comment come along under ids of their own. let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o { InlineObject::NoteRef { id, .. } => (Some(*id), c), @@ -720,6 +723,55 @@ fn adopting_a_document_renumbers_what_it_refers_to() { assert_eq!(text(our_note).as_deref(), Some("our note")); } +#[test] +fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() { + use crate::styles::{Style, StyleKind, StyleSheet}; + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + theirs.styles = StyleSheet::empty(); + theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() }; + let big = CharProps { size: Some(20.0), ..Default::default() }; + theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() }); + theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Big".into()); + p.runs[0].props.style = Some("Link".into()); + + let frag = ours.adopt_with(theirs, true); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0); +} + +#[test] +fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() { + use crate::styles::Style; + let mut ours = doc(&["ours"]); + ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let mut theirs = doc(&["theirs"]); + theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered); + let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap(); + let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap(); + a.levels[0].style = Some("Heading1".into()); + let level = a.levels[0].clone(); + theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level)); + + let before = ours.numbering.abstracts.len(); + ours.adopt_with(theirs, true); + let copied = &ours.numbering.abstracts[before..]; + let styles: Vec<_> = copied + .iter() + .flat_map(|a| a.levels.iter()) + .chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l))) + .filter_map(|l| l.style.clone()) + .collect(); + assert_eq!(styles.len(), 2, "{styles:?}"); + for st in &styles { + assert_ne!(st, "Heading1"); + assert!(ours.styles.get(st).is_some(), "{st}"); + } +} + #[test] fn an_adopted_section_breaks_tracked_change_names_its_revision() { use crate::{Revision, RevisionKind, SectionProps}; @@ -737,36 +789,47 @@ fn an_adopted_section_breaks_tracked_change_names_its_revision() { } #[test] -fn an_adopted_tracked_format_change_keeps_its_old_list() { +fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() { use crate::props::{NumRef, ParaProps, PropChange}; use crate::{Revision, RevisionKind}; - let mut ours = doc(&["ours"]); - let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); - let mut theirs = doc(&["theirs"]); - theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); - let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); - assert_eq!(list, our_list); - let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - p.props.numbering = Some(NumRef { num: list, level: 0 }); - p.props.fmt_change = PropChange::boxed(0, ParaProps { numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() }); + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + // Live and old props name the same styles (ours has them too) and their list. + p.props.style = Some("Quote".into()); + p.props.numbering = Some(NumRef { num: list, level: 0 }); + let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() }; + p.props.fmt_change = PropChange::boxed(0, old); + p.runs[0].props.style = Some("Strong".into()); + p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() }); - let frag = ours.adopt(theirs); - let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; - let old = &p.props.fmt_change.as_ref().unwrap().old; - assert_eq!(old.numbering, p.props.numbering); - assert_ne!(old.numbering.map(|n| n.num), Some(our_list)); + let frag = ours.adopt_with(theirs, keep); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let old = &p.props.fmt_change.as_ref().unwrap().old; + assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}"); + assert_ne!(old.numbering.map(|n| n.num), Some(our_list)); + let run = &p.runs[0].props; + assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}"); + } } #[test] fn adopted_styles_never_become_our_defaults() { use crate::styles::{Style, StyleKind}; - let mut ours = doc(&["ours"]); - let mut theirs = doc(&["theirs"]); - // A localized default character style, and one whose id we have too. - theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); - theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); - ours.adopt(theirs); - assert!(ours.styles.styles.iter().all(|s| !s.default)); + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + // A localized default character style, and one whose id we have too. + theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + ours.adopt_with(theirs, keep); + assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}"); + } } #[test] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 4ae157deb..12189ac5a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -448,6 +448,25 @@ fn box_para(story: u32, id: u32) -> Blocks { vec![para_block(p)] } +#[test] +fn adopting_relinks_lists_in_deeply_nested_tables() { + // As #1339: a list paragraph 20 tables down takes the list it is brought in with. + let mut ours = Document::from_text("ours"); + let our_list = ours.numbering.add_list(numbering::ListKind::Bullet); + let mut theirs = Document::from_text("theirs"); + let list = theirs.numbering.add_list(numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let mut p = Paragraph::with_text("item", CharProps::default()); + p.props.numbering = Some(props::NumRef { num: list, level: 0 }); + theirs.body = nested_tables(20, vec![para_block(p)]); + let mut nums = Vec::new(); + for b in &ours.adopt(theirs).blocks { + edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num))); + } + assert_eq!(nums.len(), 1); + assert_ne!(nums[0], our_list); +} + #[test] fn text_boxes_shown_from_deeply_nested_tables_are_kept() { // #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken diff --git a/crates/docx/src/read/chunk.rs b/crates/docx/src/read/chunk.rs index 782a5d06e..b97aa4b47 100644 --- a/crates/docx/src/read/chunk.rs +++ b/crates/docx/src/read/chunk.rs @@ -62,7 +62,8 @@ impl Reader<'_> { let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string(); let chunk = AltChunk { content_type: &ct, part: &part, bytes }; // A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged). - let doc = if bytes.starts_with(b"PK\x03\x04") { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) }; + let is_docx = bytes.starts_with(b"PK\x03\x04"); + let doc = if is_docx { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) }; let Some(mut doc) = doc else { log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in"); return; @@ -76,7 +77,10 @@ impl Reader<'_> { p.section = None; } } - let mut blocks = self.doc.adopt_blocks(doc, &mut self.next_perm); + // `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting + // is their own already). + let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off); + let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm); // Bookmarks and comment marks read before the chunk stay before it. self.flush_pending_before(sc, out, &mut blocks); out.extend(blocks.into_iter().map(Arc::new)); diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs index 9e0107682..dd2dddbde 100644 --- a/crates/docx/tests/it/alt_chunks.rs +++ b/crates/docx/tests/it/alt_chunks.rs @@ -16,18 +16,33 @@ fn p(text: &str) -> String { /// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`. fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec { + package_styled(body, chunk, None) +} + +/// [`package`] with a styles part holding `styles`. +fn package_styled(body: &str, chunk: Option<(&str, &str, &[u8])>, styles: Option<&str>) -> Vec { let doc = format!(r#"{body}"#); let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; let mut types = String::from( r#""#, ); + let mut rels = String::new(); if let Some((ext, ct, bytes)) = chunk { types.push_str(&format!(r#""#)); + rels.push_str(&format!(r#""#)); + files.push((format!("word/afchunk.{ext}"), bytes.to_vec())); + } + if let Some(styles) = styles { + rels.push_str( + r#""#, + ); + files.push(("word/styles.xml".into(), format!(r#"{styles}"#).into_bytes())); + } + if !rels.is_empty() { files.push(( "word/_rels/document.xml.rels".into(), - format!(r#""#).into_bytes(), + format!(r#"{rels}"#).into_bytes(), )); - files.push((format!("word/afchunk.{ext}"), bytes.to_vec())); } types.push_str(r#""#); files.push(("[Content_Types].xml".into(), types.into_bytes())); @@ -210,6 +225,79 @@ fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() assert_eq!(d.sections().len(), 1); } +/// Styles a matchSrc chunk brings under new ids get distinct ones: with `A` and `A1` ours and +/// `A` to `A10` the chunk's, its `A` and `A1` both renamed don't land on one id (the second then +/// lost, its paragraphs taking the first's formatting), and keep their formatting through a save. +#[test] +fn match_src_gives_each_renamed_style_its_own_id() { + let style = |id: &str, half_points: u32| { + format!(r#""#) + }; + let ours: String = ["A", "A1"].iter().map(|id| style(id, 22)).collect(); + let mut theirs = style("A", 20) + &style("A1", 28); + for n in 2..=10 { + theirs += &style(&format!("A{n}"), 16); + } + let para = |id: &str, text: &str| format!(r#"{text}"#); + let inner = package_styled(&(para("A", "ten") + ¶("A1", "fourteen")), None, Some(&theirs)); + let body = format!(r#"{}"#, p("Ours")); + let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(&ours))).unwrap(); + let check = |d: &Document| { + let look = |text: &str| { + d.body + .iter() + .find_map(|b| match &**b { + Block::Para(p) if p.text == text => { + Some((p.props.style.clone(), d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size)) + } + _ => None, + }) + .unwrap() + }; + let ((ten, ten_size), (fourteen, fourteen_size)) = (look("ten"), look("fourteen")); + assert_ne!(ten, fourteen); + assert_eq!((ten_size, fourteen_size), (10.0, 14.0)); + }; + check(&d); + check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +#[test] +fn match_src_keeps_a_word_chunks_own_formatting() { + // Both define Normal and Subtitle differently, and the chunk's defaults differ from ours. + let ours = r#""#; + let theirs = r#""#; + let inner = package_styled( + &format!(r#"{}Sub"#, p("Plain")), + None, + Some(theirs), + ); + for match_src in [true, false] { + let pr = if match_src { "" } else { "" }; + let body = format!(r#"{}{pr}"#, p("Ours")); + let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(ours))).unwrap(); + let look = |i: usize| match &*d.body[i] { + Block::Para(p) => { + let para = d.styles.resolve_para(&p.props); + let chr = d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); + (para.space_after, chr.size, chr.italic) + } + b => panic!("{b:?}"), + }; + // Ours stays ours either way. + assert_eq!(look(0), (12.4, 11.0, false)); + if match_src { + // The chunk's own Normal (its 12 pt, Word's no space after) and its Subtitle (not italic). + assert_eq!(look(1), (0.0, 12.0, false)); + assert_eq!(look(2), (0.0, 12.0, false)); + } else { + // Without it the chunk takes our styles (our Subtitle stands on our defaults). + assert_eq!(look(1), (12.4, 11.0, false)); + assert_eq!(look(2), (10.0, 11.0, true)); + } + } +} + #[test] fn nested_wordprocessing_chunks_stop_at_a_depth() { let mut bytes = package(&p("Deepest"), None); From ee8622ddb3e27605503132c413b2d937c7292a2e Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:55:59 +0500 Subject: [PATCH 17/20] Count the runs proofing text walks, instead of timing it (#5185) (#5190) * Count the runs proofing text walks, instead of timing it (#5185) `proof_text_walks_the_runs_once` was the one linear-time test here that timed its walk, and the ratio crept over its 24x limit when the crate's tests ran in parallel -- 28.2, 26.4, 25.6 -- from cache and memory contention rather than from any regression. The file's own module doc says why the others don't have this problem: "The cost is the steps the lookups' walks count (crate::work), not a timing, so a busy machine can't make a linear lookup look quadratic or the other way round." It then says this one is timed because "proofing text, whose walk is the document model's" counts no steps -- but `proof_text` does the walk itself, advancing a runs iterator alongside the text, so it can count the runs it looks at like every other walk here. Nothing in `wordcraft-doc` had to change. before 8x the runs took 28.2x the time (limit 24), under load after 50000 steps for n, 400000 for 8n, ratio 8.0 (limit 12) The count is the same on any machine under any load, so the flakiness is gone rather than made less likely: raising the limit to 32x would have left the same failure available on a busier machine. It is also about 100x faster -- 0.03 s against three rounds of eight repetitions -- and `work::steps` is `#[cfg(test)]` inside, so release builds are untouched. The test still catches what it was written for. Putting the quadratic walk back (the run for each character found from the first run, every time) gives: proof_text: 1250025001 steps for n, 80000200001 for 8n, ratio 64.0 panicked: 8x the input took 64.0x the steps: not linear Verified under the reported condition: three runs of the whole crate suite in parallel, 559 tests, all green. * linear tests: proofing text is counted now, not timed --------- Co-authored-by: Brandon Thomas --- crates/layout/src/para/proof.rs | 5 +++++ crates/layout/src/tests/linear.rs | 27 ++++++++------------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/layout/src/para/proof.rs b/crates/layout/src/para/proof.rs index 1cec32e4d..b9ffa911c 100644 --- a/crates/layout/src/para/proof.rs +++ b/crates/layout/src/para/proof.rs @@ -79,10 +79,15 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String { let mut runs = p.runs.iter(); let mut run = runs.next(); let mut end = run.map_or(0, |r| r.len); + // Each run this walk looks at is a step ([`crate::work`]), so the linear-time test counts + // them instead of timing the walk: a caller that went back to the first run for every + // character would count the runs once per character and show up as a quadratic count (#5185). + crate::work::steps(usize::from(run.is_some())); for (i, c) in p.text.char_indices() { while run.is_some() && i >= end { run = runs.next(); end = end.saturating_add(run.map_or(0, |r| r.len)); + crate::work::steps(usize::from(run.is_some())); } let props = run.map_or(&p.mark, |r| &r.props); let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some(); diff --git a/crates/layout/src/tests/linear.rs b/crates/layout/src/tests/linear.rs index 30db6ec00..993dcbedd 100644 --- a/crates/layout/src/tests/linear.rs +++ b/crates/layout/src/tests/linear.rs @@ -2,10 +2,9 @@ //! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. //! //! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy -//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, -//! whose walk is the document model's, is timed. +//! machine can't make a linear lookup look quadratic or the other way round. -use super::{SCALE, cost_ratio}; +use super::SCALE; use crate::{ Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work, @@ -56,22 +55,12 @@ fn proof_text_walks_the_runs_once() { p }; assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); - // The runs are the document model's, whose walk counts no steps, so this one is timed: the - // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for - // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over - // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. - let (small, big) = (para(50_000), para(SCALE * 50_000)); - let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); - let mut ratios = Vec::new(); - for _ in 0..3 { - let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); - eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - if ratio < 24.0 { - return; - } - ratios.push(ratio); - } - panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); + // The runs the walk looks at are counted now ([`crate::work`]), as every other walk here is, + // so this no longer depends on a timing: the ratio was creeping over its limit when the + // crate's tests ran in parallel, from cache and memory contention rather than from any + // regression (#5185). Going back to the first run for every character counts the runs once + // per character, which is the quadratic walk this watches for. + assert_linear_steps("proof_text", 50_000, para, |p| drop(std::hint::black_box(para::proof_text(p)))); } /// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. From 242e850aaad6bf38e1cd2be4122cc7cb9e5a15a8 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:58:18 +0100 Subject: [PATCH 18/20] #3660: refuse a package that declares too many zip entries before zip parses them (#5260) * #3660: refuse a package that declares too many zip entries before zip parses them zip::ZipArchive::new parses and keeps every central directory header the end record declares before its caller can look at len(). A ZIP64 end record can declare one per 46 bytes of the file: a 47 MB package of one stored file listed 1,000,000 times takes zip 3.0.0 about half a second and a large transient allocation, and len() then says 1, since the copies share a name, so the docx reader's MAX_ENTRIES cap never fires. ODT and spreadsheet packages had no cap at all. wordcraft_doc::zip_guard::check finds the end records the way zip finds them (backwards; the ZIP64 record from the locator's offset on; a plain record when it has no entries or a central header before it) and refuses a package declaring more than the reader's limit. The docx package reader, the ODT reader, the spreadsheet reader (mail-merge data) and the template-title probe call it first, with 20,000 entries as the limit. Co-Authored-By: Claude Opus 5.5 * formats: keep the extensions doc line on EXTENSIONS --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/Cargo.toml | 3 +- crates/doc/src/lib.rs | 1 + crates/doc/src/zip_guard.rs | 191 +++++++++++++++++++++++++++++++ crates/docx/src/lib.rs | 16 ++- crates/docx/src/package.rs | 17 +++ crates/formats/src/lib.rs | 4 + crates/formats/src/odt/import.rs | 1 + crates/formats/src/sheet.rs | 9 ++ 8 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 crates/doc/src/zip_guard.rs diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index aff28f8d9..6da6ec45f 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -9,7 +9,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect workspace = true [features] -# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a +# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`, +# `zip_guard::zip64_entry_count_sample`); turned on only as a # dev-dependency, so never built into the app. test-support = [] diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d0100eb0..bcdd02948 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -48,6 +48,7 @@ pub mod symbol_fonts; pub mod table; pub mod wordart; pub mod wrap; +pub mod zip_guard; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/crates/doc/src/zip_guard.rs b/crates/doc/src/zip_guard.rs new file mode 100644 index 000000000..0610cac1e --- /dev/null +++ b/crates/doc/src/zip_guard.rs @@ -0,0 +1,191 @@ +//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it +//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first. +//! +//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the +//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare +//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and +//! each parsed entry costs several times that in memory. Here the end records are found the way +//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the +//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for +//! `zip` to report. + +const EOCD: &[u8; 4] = b"PK\x05\x06"; +const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07"; +const EOCD64: &[u8; 4] = b"PK\x06\x06"; +const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02"; + +/// End records looked at, at most. A package has one; a few more can turn up inside stored data. +const MAX_END_RECORDS: usize = 16; + +/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries. +/// +/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points +/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset +/// on: every such record there is checked, and as `zip` may fall back to an earlier end record +/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or +/// a central directory header lies between its directory offset and itself: it is the last one +/// looked at. +pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes); + let refuse = |n: u64| -> Result<(), String> { + if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) } + }; + let mut end = bytes.len(); + let mut seen = 0; + while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) { + end = at; + seen += 1; + if seen > MAX_END_RECORDS { + return Err(format!("more than {MAX_END_RECORDS} end of central directory records")); + } + // `zip` skips a record whose comment runs past the end of the file. + let Some(comment) = u16_at(at + 20) else { continue }; + if at + 22 + usize::from(comment) > bytes.len() { + continue; + } + let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue }; + let zip64 = total == u16::MAX || cd_offset == u32::MAX; + if zip64 + && let Some(locator) = at.checked_sub(20) + && bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice()) + { + let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue }; + let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue }; + if disks > 1 { + continue; + } + let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64); + for (i, _) in records { + let p = from + i; + refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?; + } + continue; + } + if total == 0 { + return Ok(()); + } + let cd = cd_offset as usize; + if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) { + return refuse(u64::from(on_disk.max(total))); + } + } + Ok(()) +} + +/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at +/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0 +/// parses and keeps every one of them before its caller can count them. Regression input for the +/// readers' tests: built only for tests, this crate's and (through the `test-support` feature, +/// which they turn on as a dev-dependency) the readers', never into the app. +#[cfg(any(test, feature = "test-support"))] +pub fn zip64_entry_count_sample(entries: u64) -> Vec { + // Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a". + let mut f = b"PK\x03\x04".to_vec(); + f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date + f.extend_from_slice(&[0; 12]); // crc, sizes + f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths + f.push(b'a'); + let cd_at = f.len() as u64; + // Its central header (§4.3.12), 47 bytes. + let mut header = CENTRAL_HEADER.to_vec(); + header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date + header.extend_from_slice(&[0; 12]); // crc, sizes + header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes + header.extend_from_slice(&[0; 4]); // local header offset + header.push(b'a'); + let count = entries.min((64 << 20) / header.len() as u64); + for _ in 0..count { + f.extend_from_slice(&header); + } + let cd_len = f.len() as u64 - cd_at; + let eocd64_at = f.len() as u64; + // ZIP64 end of central directory record (§4.3.14), 56 bytes. + f.extend_from_slice(EOCD64); + f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record + f.extend_from_slice(&[45, 0, 45, 0]); // versions + f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory + f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk + f.extend_from_slice(&entries.to_le_bytes()); // entries + f.extend_from_slice(&cd_len.to_le_bytes()); + f.extend_from_slice(&cd_at.to_le_bytes()); + // Its locator (§4.3.15), 20 bytes. + f.extend_from_slice(EOCD64_LOCATOR); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&eocd64_at.to_le_bytes()); + f.extend_from_slice(&1u32.to_le_bytes()); + // End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64". + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(&[0xFF; 12]); + f.extend_from_slice(&[0; 2]); + f +} + +#[cfg(test)] +mod tests { + use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample}; + + /// A plain package's tail: one central header at 0, then an end record declaring `entries`. + fn plain(entries: u16, comment: &[u8]) -> Vec { + let mut f = CENTRAL_HEADER.to_vec(); + f.resize(46, 0); + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&entries.to_le_bytes()); + f.extend_from_slice(&entries.to_le_bytes()); + f.extend_from_slice(&46u32.to_le_bytes()); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&(comment.len() as u16).to_le_bytes()); + f.extend_from_slice(comment); + f + } + + #[test] + fn a_zip64_end_record_declaring_too_many_entries_is_refused() { + // #3660: `zip` would reserve and parse them all before the reader's cap. + let sample = zip64_entry_count_sample(20_001); + assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries"))); + assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok()); + // Junk before the package (a self-extracting stub) doesn't hide it. + let mut prefixed = vec![0x55u8; 4096]; + prefixed.extend_from_slice(&sample); + assert!(check(&prefixed, 20_000).is_err()); + } + + #[test] + fn a_plain_end_record_is_checked_as_zip_reads_it() { + assert!(check(&plain(3, b""), 20_000).is_ok()); + assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries"))); + // A record whose comment runs past the end is not the one `zip` uses. + let mut cut = plain(30_000, b""); + cut.truncate(cut.len() - 2); + cut.extend_from_slice(&500u16.to_le_bytes()); + assert!(check(&cut, 20_000).is_ok()); + assert!(check(b"not a package", 20_000).is_ok()); + assert!(check(b"", 20_000).is_ok()); + } + + #[test] + fn a_fake_end_record_after_the_real_one_does_not_hide_it() { + // `zip` skips a last record with no central header before it and uses the one before. + let mut f = zip64_entry_count_sample(1_000_000); + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&7u16.to_le_bytes()); + f.extend_from_slice(&7u16.to_le_bytes()); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes()); + f.extend_from_slice(&0u16.to_le_bytes()); + assert!(check(&f, 20_000).is_err()); + } + + #[test] + fn many_end_records_are_refused() { + // Each declares one entry and has no central header before it, so `zip` would try them all. + let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let f: Vec = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect(); + assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records"))); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 72cfdebfa..074877c37 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result { /// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376 /// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's -/// directory and at most `max_part` bytes of that part are read: for listing templates by name -/// without reading the whole package. -pub fn package_title(package: R, max_part: u64) -> Option { +/// directory and at most `max_part` bytes of that part are inflated: for listing templates by +/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries +/// than `MAX_ENTRIES` (20,000), has none (#3660). +pub fn package_title(package: R, max_part: u64) -> Option { use std::io::Read; - let mut zip = zip::ZipArchive::new(package).ok()?; + const MAX_PACKAGE: u64 = 64 << 20; + let mut bytes = Vec::new(); + package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_PACKAGE { + return None; + } + wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?; let part = zip.by_name("docProps/core.xml").ok()?; let mut xml = Vec::new(); part.take(max_part).read_to_end(&mut xml).ok()?; diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index bdefc38fa..df812fa24 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -212,6 +212,8 @@ impl Package { } pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result { + // Before `zip` parses every entry the end record declares (#3660). + wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?; let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?; if zip.len() > MAX_ENTRIES { return Err(DocxError::Limit(format!("{} zip entries", zip.len()))); @@ -524,6 +526,21 @@ mod tests { assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}"); } + #[test] + fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() { + // #3660: a ZIP64 end record declaring more than MAX_ENTRIES. + let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1); + assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares"))); + assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None); + // A real package still opens, and its title still reads. + let z = zip_of(&[( + "docProps/core.xml", + br#"Plan"#.to_vec(), + )]); + assert!(Package::open(&z).is_ok()); + assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan")); + } + #[test] fn decompression_bomb_is_rejected() { let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]); diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 8af0d2a50..9d66c8cfc 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -22,6 +22,10 @@ pub mod txt; use wordcraft_doc::Document; +/// Most entries an ODT or spreadsheet package may declare, as many as a Word package +/// (`wordcraft_docx::package::MAX_ENTRIES`); checked before `zip` parses them (#3660). +pub const MAX_ZIP_ENTRIES: u64 = 20_000; + /// Extensions this crate handles. pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "tex", "latex", "ltx"]; diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 61d446004..39742d3e8 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -115,6 +115,7 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { /// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes). fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Option, page_parts::Parts), String> { + wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES).map_err(|e| format!("ODT: {e}"))?; let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?; if let Some(mt) = read_entry(&mut z, "mimetype") { let mt = String::from_utf8_lossy(&mt); diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 8e352dd92..53c7fca84 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -201,6 +201,7 @@ struct Package<'a> { impl<'a> Package<'a> { fn open(bytes: &'a [u8]) -> Result { + wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES)?; let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a valid spreadsheet package ({e})"))?; Ok(Package { zip, total: 0 }) } @@ -401,6 +402,14 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn a_package_declaring_too_many_entries_is_refused() { + // #3660: refused before `zip` reserves and parses them. + let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(crate::MAX_ZIP_ENTRIES + 1); + assert!(sheet_names(&bomb).is_err_and(|e| e.contains("declares"))); + assert!(crate::odt::parse(&bomb).is_err_and(|e| e.contains("declares"))); + } + #[test] fn negative_zero_formats_as_zero() { assert_eq!(number(-0.0), "0"); From 4f2d0da04c01bfebc862ea5354d6db65709e543c Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Mon, 2 Nov 2026 15:53:00 +0000 Subject: [PATCH 19/20] Shapes: align soft-edge paint and caret opacity (#1600) * Shapes: a solid fill keeps its transparency A shape's fill colour can carry a:alpha, its opacity. WordCraft dropped it, so a translucent white wash laid over a picture was drawn opaque and hid the picture. The transparency is now read into the shape, drawn on screen and in the PDF, and written back. * Fix #1600 freeform and soft-edge fill opacity * #1600: wrap canonical caret sampler signature * #1600: format canonical integration * #1600: assert canonical DOCX alpha round-trip * #1600: document canonical shape opacity API * Adapt alpha fixture to rectangle render bounds * docs: one file-format-parity row for soft-edge fill opacity, status line --------- Co-authored-by: Brandon Thomas --- crates/docx/tests/it/fixtures.rs | 24 +++ crates/layout/src/display/objects.rs | 3 + .../src/display/objects/fill_alpha_tests.rs | 83 +++++++++ crates/layout/src/display/paint.rs | 10 ++ crates/layout/src/tests.rs | 2 + crates/layout/src/tests/shape_alpha.rs | 24 +++ crates/pdf/src/exporter/draw.rs | 4 +- crates/pdf/src/fill_alpha_tests.rs | 161 ++++++++++++++++++ crates/pdf/src/lib.rs | 3 + crates/render/src/fill_alpha_tests.rs | 123 +++++++++++++ crates/render/src/lib.rs | 35 +++- docs/file-format-parity.md | 3 +- 12 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 crates/layout/src/display/objects/fill_alpha_tests.rs create mode 100644 crates/layout/src/tests/shape_alpha.rs create mode 100644 crates/pdf/src/fill_alpha_tests.rs create mode 100644 crates/render/src/fill_alpha_tests.rs diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 83a8d4cbf..dd5169d9a 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -4092,6 +4092,30 @@ fn equation_in_alternate_content_is_read() { assert!(!p.plain_text().contains("[picture]")); } +/// A fill's `a:alpha` is its opacity: a translucent white wash over a picture stayed opaque and +/// hid the picture. +#[test] +fn a_shape_fill_keeps_its_transparency() { + let fill = + r#""#; + let d = read_body(&format!("{}", anchored_wsp("Wash", 1270000, 635000, fill, "", ""))); + let transparency = |d: &Document| match ¶s(d)[0].objects[0] { + InlineObject::Shape { fill: Some(wordcraft_doc::fill::Fill::Solid { color, transparency }), .. } => (Some(*color), *transparency), + o => panic!("{o:?}"), + }; + const FILL_ALPHA: u32 = 30_980; + const ALPHA_SCALE: f32 = 100_000.0; + // Canonical Fill stores the percentage computed from opacity, including f32 rounding. + let expected = 100.0 - (FILL_ALPHA as f32 / ALPHA_SCALE) * 100.0; + assert_eq!(transparency(&d), (Some(Rgb(255, 255, 255)), expected)); + let bytes = wordcraft_docx::write(&d).unwrap(); + let parts = unzip(&bytes); + let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap()); + assert!(xml.contains(&format!("")), "{xml}"); + let again = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(transparency(&again), (Some(Rgb(255, 255, 255)), expected)); +} + /// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's /// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use), /// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index c6119e525..9b5f3c075 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -506,3 +506,6 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw { cap: Default::default(), } } + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/layout/src/display/objects/fill_alpha_tests.rs b/crates/layout/src/display/objects/fill_alpha_tests.rs new file mode 100644 index 000000000..25f175c9e --- /dev/null +++ b/crates/layout/src/display/objects/fill_alpha_tests.rs @@ -0,0 +1,83 @@ +use super::*; +use wordcraft_doc::connector::ShapeExtra; +use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; + +#[test] +fn freeform_fill_has_own_alpha() { + let rect = Rect::new(4.0, 4.0, 24.0, 24.0); + for transparency in [0.0, 50.0, 100.0] { + for closed in [false, true] { + let freeform = Freeform { + w: 24.0, + h: 24.0, + paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0]], closed }], + alpha: 0.25, + ..Default::default() + }; + let mut out = Vec::new(); + shape_draws( + rect, + &ShapeLook { + kind: ShapeKind::Freeform, + fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency }), + stroke: Some(Rgb::WHITE), + stroke_width: 2.0, + effects: Default::default(), + freeform: Some(&freeform), + extra: &ShapeExtra::default(), + }, + &DisplayOptions::default(), + &mut out, + ); + let Draw::Path { fill, stroke, stroke_width, .. } = &out[0] else { panic!("expected freeform path") }; + match fill { + Some(Paint::Solid(color, alpha)) if closed => { + assert_eq!(*color, Rgb::BLACK); + assert_eq!(*alpha, 1.0 - transparency / 100.0); + } + None if !closed => {} + _ => panic!("unexpected freeform paint: {fill:?}"), + } + assert_eq!((*stroke, *stroke_width), (Some(Rgb::WHITE), 2.0)); + } + } + let ink = Freeform::ink(InkTool::Highlighter, 24.0, 24.0, vec![[0.0, 0.0], [24.0, 24.0]]); + let mut out = Vec::new(); + shape_draws( + rect, + &ShapeLook { + kind: ShapeKind::Freeform, + fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency: 100.0 }), + stroke: Some(Rgb::WHITE), + stroke_width: 2.0, + effects: Default::default(), + freeform: Some(&ink), + extra: &ShapeExtra::default(), + }, + &DisplayOptions::default(), + &mut out, + ); + assert!(matches!(out.as_slice(), [Draw::Ink { alpha, .. }] if *alpha == 0.5)); +} + +#[test] +fn other_path_alpha_defaults() { + let rect = Rect::new(4.0, 4.0, 24.0, 24.0); + let graphic = Graphic { + items: vec![GraphicItem::Path { + segs: vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(24.0, 24.0)], + fill: Some(Rgb::BLACK), + stroke: None, + stroke_width: 0.0, + }], + ..Default::default() + }; + let mut out = graphic_draws(&Document::new(), &graphic, rect, 1.0); + out.push(path_arrowhead(&[(4.0, 4.0), (28.0, 28.0)], Rgb::BLACK, 2.0, ArrowStyle::default()).unwrap()); + let extra = ShapeExtra { arrow_start: true, arrow_end: true, ..Default::default() }; + line_draws(rect, ShapeKind::ElbowConnector, Some(Rgb::BLACK), 2.0, Default::default(), &extra, &mut out); + assert_eq!(out.len(), 5); + assert!(out.iter().all( + |item| matches!(item, Draw::Path { fill, .. } if fill.as_ref().is_none_or(|paint| matches!(paint, Paint::Solid(_, alpha) if *alpha == 1.0))) + )); +} diff --git a/crates/layout/src/display/paint.rs b/crates/layout/src/display/paint.rs index b5cb62f7d..7509a5062 100644 --- a/crates/layout/src/display/paint.rs +++ b/crates/layout/src/display/paint.rs @@ -32,6 +32,16 @@ impl Paint { Paint::Radial { c, r, stops } => Paint::Radial { c: *c, r: *r, stops: fade(stops) }, } } + /// Inset soft-edge paints. Solid bands composite to the fill's target opacity. + pub fn soft_bands(&self, radius: f32) -> Vec<(f32, Paint)> { + match self { + Paint::Solid(color, alpha) => { + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + wordcraft_doc::effects::bands(-radius, 0.0, alpha).into_iter().map(|(grow, band)| (grow, Paint::Solid(*color, band))).collect() + } + _ => wordcraft_doc::effects::bands(-radius, 0.0, 1.0).into_iter().map(|(grow, alpha)| (grow, self.faded(alpha))).collect(), + } + } /// The colour and opacity this paint gives the point (`x`, `y`): stops blended, padded past /// the ends. pub fn at(&self, x: f32, y: f32) -> (Rgb, f32) { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5caf63784..a836099af 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -12762,3 +12762,5 @@ fn header_margin_table_area() { } } } + +mod shape_alpha; diff --git a/crates/layout/src/tests/shape_alpha.rs b/crates/layout/src/tests/shape_alpha.rs new file mode 100644 index 000000000..49bedc553 --- /dev/null +++ b/crates/layout/src/tests/shape_alpha.rs @@ -0,0 +1,24 @@ +use super::*; + +#[test] +fn a_translucent_shape_fill_is_drawn_translucent() { + let mut d = Document::from_text("Wash"); + let wash = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::Rectangle, + w: 100.0, + h: 50.0, + fill: Some(wordcraft_doc::fill::Fill::Solid { color: wordcraft_doc::props::Rgb::WHITE, transparency: 69.0 }), + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), wash, &Default::default()).unwrap(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let alpha = + draws.iter().find_map(|x| if let display::Draw::Shape { fill: Some(display::Paint::Solid(_, alpha)), .. } = x { Some(*alpha) } else { None }); + assert!(alpha.is_some_and(|a| (a - 0.31).abs() < 0.001), "fill opacity {alpha:?}"); +} diff --git a/crates/pdf/src/exporter/draw.rs b/crates/pdf/src/exporter/draw.rs index 73b6d5e1a..f2e9d0ab5 100644 --- a/crates/pdf/src/exporter/draw.rs +++ b/crates/pdf/src/exporter/draw.rs @@ -104,9 +104,9 @@ impl Exporter<'_> { if let (Some(rad), Some(c), true) = (fx.soft_edge, f.as_ref(), can_fill) { let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0); s.set_stroke(None); - for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) { + for (g, paint) in c.soft_bands(rad) { if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) { - s.set_fill(Some(paint_fill(&c.faded(a)))); + s.set_fill(Some(paint_fill(&paint))); s.draw_path(&p); } } diff --git a/crates/pdf/src/fill_alpha_tests.rs b/crates/pdf/src/fill_alpha_tests.rs new file mode 100644 index 000000000..b90381f1c --- /dev/null +++ b/crates/pdf/src/fill_alpha_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use hayro_interpret::font::Glyph; +use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, +}; +use kurbo::Shape; +use wordcraft_doc::effects::ShapeEffects; +use wordcraft_doc::freeform::{FreePath, Freeform}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::{CharProps, Document, Paragraph, para_block}; + +const ALPHA_TOLERANCE: f32 = 0.015; +const RED: wordcraft_doc::Rgb = wordcraft_doc::Rgb(255, 0, 0); +const GREEN: wordcraft_doc::Rgb = wordcraft_doc::Rgb(0, 255, 0); + +struct PaintedPath { + path: kurbo::BezPath, + color: [u8; 4], +} + +struct Paints { + fills: Vec, + strokes: Vec, + groups: Vec, +} + +impl Device<'_> for Paints { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) { + let Paint::Color(color) = paint else { panic!("solid fixture paint") }; + let mut color = color.to_rgba().to_rgba8(); + color[3] = (f32::from(color[3]) * self.groups.last().copied().unwrap_or(1.0)).round() as u8; + let record = PaintedPath { path: transform * path.clone(), color }; + match mode { + PathDrawMode::Fill(_) => self.fills.push(record), + PathDrawMode::Stroke(_) => self.strokes.push(record), + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn pop_clip_path(&mut self) {} + fn push_transparency_group(&mut self, alpha: f32, _: Option>, _: BlendMode) { + self.groups.push(self.groups.last().copied().unwrap_or(1.0) * alpha); + } + fn pop_transparency_group(&mut self) { + assert!(self.groups.len() > 1); + let _ = self.groups.pop(); + } + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} +} + +impl Paints { + fn red_coverage(&self, point: (f64, f64)) -> f32 { + 1.0 - self + .fills + .iter() + .filter(|r| r.color[..3] == [255, 0, 0] && r.path.contains(point.into())) + .map(|r| 1.0 - f32::from(r.color[3]) / 255.0) + .product::() + } +} + +fn paints(shape: InlineObject) -> Paints { + let mut doc = Document::new(); + let mut para = Paragraph::new(); + para.insert_object(0, shape, &CharProps::default()).unwrap(); + doc.body = vec![para_block(para)]; + let bytes = export(&doc, &PdfOptions { tagged: false, ..Default::default() }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let cache = InterpreterCache::new(); + let mut paints = Paints { fills: Vec::new(), strokes: Vec::new(), groups: vec![1.0] }; + for page in pdf.pages().iter() { + let bounds = page.media_box(); + let (width, height) = (bounds.width(), bounds.height()); + // PDF uses bottom-up coordinates; the fixture points use the layout's top-down frame. + let top_down = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]); + let mut ctx = Context::new(top_down, kurbo::Rect::new(0.0, 0.0, width, height), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut paints); + } + assert_eq!(paints.groups, [1.0]); + paints +} + +fn shape(kind: ShapeKind, transparency: f32, effects: ShapeEffects) -> InlineObject { + InlineObject::Shape { + kind, + w: 24.0, + h: 24.0, + fill: Some(wordcraft_doc::fill::Fill::Solid { color: RED, transparency }), + stroke: None, + stroke_width: 0.0, + float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() }, + story: None, + freeform: None, + effects, + extra: Default::default(), + } +} + +fn close(actual: f32, want: f32) { + assert!((actual - want).abs() < ALPHA_TOLERANCE, "{actual} != {want}"); +} + +#[test] +fn freeform_fill_keeps_outline() { + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] { + let mut item = shape(ShapeKind::Freeform, transparency, ShapeEffects::default()); + if let InlineObject::Shape { freeform, stroke, stroke_width, .. } = &mut item { + *freeform = Some(std::sync::Arc::new(Freeform { + w: 24.0, + h: 24.0, + paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0], [0.0, 24.0]], closed: true }], + alpha: 0.25, + ..Default::default() + })); + *stroke = Some(GREEN); + *stroke_width = 2.0; + } + let paints = paints(item); + close(paints.red_coverage((112.0, 112.0)), alpha); + assert_eq!(paints.strokes.len(), 1); + assert_eq!(paints.strokes[0].color, [0, 255, 0, 255]); + } +} + +#[test] +fn soft_edge_target_alpha() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] { + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0), (f32::NAN, 1.0)] { + let effects = ShapeEffects { soft_edge: Some(radius), ..Default::default() }; + let paints = paints(shape(kind, transparency, effects)); + close(paints.red_coverage((112.0, 112.0)), alpha); + close(paints.red_coverage((101.125, 112.0)), alpha / count); + close(paints.red_coverage((99.0, 112.0)), 0.0); + assert!(paints.strokes.is_empty()); + } + } + } +} + +#[test] +fn soft_edge_keeps_effect_paint() { + let effects = ShapeEffects { + shadow: Some(wordcraft_doc::effects::Shadow::default()), + glow: Some(wordcraft_doc::effects::Glow::default()), + soft_edge: Some(6.0), + }; + let opaque = paints(shape(ShapeKind::Rectangle, 0.0, effects)); + let clear = paints(shape(ShapeKind::Rectangle, 100.0, effects)); + let other = |r: &&PaintedPath| r.color[..3] != [255, 0, 0]; + let before: Vec<_> = opaque.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect(); + let after: Vec<_> = clear.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect(); + assert!(!before.is_empty()); + assert_eq!(before, after); + assert!(opaque.strokes.is_empty() && clear.strokes.is_empty()); + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] { + close(paints(shape(ShapeKind::Rectangle, transparency, ShapeEffects::default())).red_coverage((112.0, 112.0)), alpha); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 78a27d35e..739108e1b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -76,3 +76,6 @@ use picture::{load_image, load_image_within}; #[cfg(test)] mod tests; + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/render/src/fill_alpha_tests.rs b/crates/render/src/fill_alpha_tests.rs new file mode 100644 index 000000000..e27776e95 --- /dev/null +++ b/crates/render/src/fill_alpha_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; +use wordcraft_doc::graphic::PathSeg; +use wordcraft_geom::Rect; + +const PIXEL_TOLERANCE: u8 = 3; +const BLUE: Rgb = Rgb(0, 0, 255); +const RED: Rgb = Rgb(255, 0, 0); +const GREEN: Rgb = Rgb(0, 255, 0); + +fn image(item: Draw) -> Rendered { + let items = [Draw::Fill { rect: Rect::new(0.0, 0.0, 40.0, 40.0), color: BLUE, alpha: 1.0, text_bg: false }, item]; + render_items(&Document::new(), &items, Rect::new(0.0, 0.0, 40.0, 40.0), 4.0, &RenderOptions::default()) +} + +fn close(pixel: [u8; 4], alpha: f32) { + let red = (255.0 * alpha).round() as u8; + let blue = (255.0 * (1.0 - alpha)).round() as u8; + let want = [red, 0, blue, 255]; + assert!(pixel.into_iter().zip(want).all(|(a, b)| a.abs_diff(b) <= PIXEL_TOLERANCE), "{pixel:?} != {want:?}"); +} + +fn soft_shape(kind: ShapeKind, alpha: f32, radius: f32, effects: ShapeEffects) -> Draw { + Draw::Shape { + rect: Rect::new(4.0, 4.0, 24.0, 24.0), + kind, + fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)), + stroke: None, + stroke_width: 0.0, + dash: Vec::new(), + cap: Default::default(), + corner: None, + effects: ShapeEffects { soft_edge: Some(radius), ..effects }, + } +} + +#[test] +fn path_fill_keeps_stroke_alpha() { + for alpha in [0.0, 0.5, 1.0] { + let item = Draw::Path { + segs: vec![PathSeg::Move(4.0, 4.0), PathSeg::Line(28.0, 4.0), PathSeg::Line(28.0, 28.0), PathSeg::Line(4.0, 28.0), PathSeg::Close], + fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)), + stroke: Some(GREEN), + stroke_width: 2.0, + dash: Vec::new(), + cap: Default::default(), + }; + let rendered = image(item); + close(rendered.pixel(64, 64), alpha); + assert_eq!(rendered.pixel(16, 64), [0, 255, 0, 255], "stroke at fill alpha {alpha}"); + } + let ink = image(Draw::Ink { pts: vec![(4.0, 16.0), (28.0, 16.0)], color: RED, width: 4.0, alpha: 0.5 }); + close(ink.pixel(64, 64), 0.5); +} + +#[test] +fn soft_edge_has_target_alpha() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] { + for alpha in [0.0, 0.5, 1.0] { + let rendered = image(soft_shape(kind, alpha, radius, ShapeEffects::default())); + close(rendered.pixel(64, 64), alpha); + close(rendered.pixel(20, 64), alpha / count); + assert_eq!(rendered.pixel(0, 64), [0, 0, 255, 255]); + } + } + let opaque = image(soft_shape(kind, 1.0, 6.0, ShapeEffects::default())); + for alpha in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2.0] { + assert_eq!(image(soft_shape(kind, alpha, 6.0, ShapeEffects::default())).pixels, opaque.pixels); + } + assert_eq!( + image(soft_shape(kind, -1.0, 6.0, ShapeEffects::default())).pixels, + image(soft_shape(kind, 0.0, 6.0, ShapeEffects::default())).pixels, + ); + } +} + +#[test] +fn soft_edge_keeps_effects() { + let effects = ShapeEffects { shadow: Some(Shadow::default()), glow: Some(Glow::default()), soft_edge: None }; + let opaque = image(soft_shape(ShapeKind::Rectangle, 1.0, 6.0, effects)); + let clear = image(soft_shape(ShapeKind::Rectangle, 0.0, 6.0, effects)); + let mut effect_pixels = 0; + for y in 0..160 { + for x in 0..160 { + if (16..112).contains(&x) && (16..112).contains(&y) { + continue; + } + assert_eq!(opaque.pixel(x, y), clear.pixel(x, y)); + effect_pixels += usize::from(opaque.pixel(x, y) != [0, 0, 255, 255]); + } + } + assert!(effect_pixels > 0); + let stroke = |alpha| { + let mut item = soft_shape(ShapeKind::Rectangle, alpha, 6.0, ShapeEffects::default()); + if let Draw::Shape { fill, stroke, stroke_width, .. } = &mut item { + *fill = None; + *stroke = Some(GREEN); + *stroke_width = 2.0; + } + image(item) + }; + assert_eq!(stroke(0.0).pixels, stroke(1.0).pixels); +} + +#[test] +fn caret_samples_soft_edge_bands() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for alpha in [0.0, 0.5, 1.0] { + let shape = soft_shape(kind, alpha, 6.0, ShapeEffects::default()); + let rendered = image(shape.clone()); + for (x, y) in [(18, 64), (22, 64), (30, 64), (64, 64)] { + let mut bg = [0.0, 0.0, 255.0]; + let point = wordcraft_geom::Point::new((x as f32 + 0.5) / 4.0, (y as f32 + 0.5) / 4.0); + blend_backgrounds(std::slice::from_ref(&shape), point, &RenderOptions::default(), &mut bg, 0); + let pixel = rendered.pixel(x, y); + for (sample, channel) in bg.into_iter().zip(pixel) { + assert!((sample - channel as f32).abs() <= PIXEL_TOLERANCE as f32, "{kind:?}, alpha={alpha}, ({x},{y}): {bg:?} vs {pixel:?}"); + } + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 853473508..3704b2f43 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -342,6 +342,28 @@ pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, Rgb(c(bg[0]), c(bg[1]), c(bg[2])) } +// Sample the same inset silhouettes and paints used by the raster and PDF. +fn soft_fill_alpha( + rect: wordcraft_geom::Rect, + kind: ShapeKind, + corner: Option, + radius: f32, + fill: &wordcraft_layout::display::Paint, + p: wordcraft_geom::Point, +) -> f32 { + let radius = radius.min(rect.w.min(rect.h) / 2.0).max(0.0); + let frame = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); + let point = kurbo::Point::new(p.x as f64, p.y as f64); + let mut opacity = 0.0; + for (grow, paint) in fill.soft_bands(radius) { + let inset = frame.inflate(grow as f64, grow as f64); + if inset.width() > 0.0 && inset.height() > 0.0 && kurbo::Shape::contains(&shape_path(kind, inset, corner), point) { + opacity += (1.0 - opacity) * paint.at(p.x, p.y).1; + } + } + opacity +} + fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) { if depth > 16 { return; @@ -351,13 +373,15 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha), // The shape's own outline (an ellipse's corners are not inside it), in its fill's // colour and opacity there (#1500: gradients and transparency). - Draw::Shape { rect, kind, fill: Some(fill), corner, .. } + Draw::Shape { rect, kind, fill: Some(fill), corner, effects, .. } if *kind != ShapeKind::Line && rect.contains(p) && { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64)) } => { - fill.at(p.x, p.y) + let (color, alpha) = fill.at(p.x, p.y); + let alpha = effects.sanitized().soft_edge.map_or(alpha, |radius| soft_fill_alpha(*rect, *kind, *corner, radius, fill, p)); + (color, alpha) } Draw::Clip { rect, items } if rect.contains(p) => { blend_backgrounds(items, p, opts, bg, depth + 1); @@ -913,10 +937,10 @@ fn shape_shadow_and_glow(ctx: &mut RenderContext, fr: &Frame, (geom, sw): (&Shap /// Soft edges: the fill fades out toward the outline (which fades with it). fn shape_soft_edges(ctx: &mut RenderContext, fr: &Frame, geom: &ShapeGeom, (rad, f): (f32, &Paint)) { - for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) { + for (g, paint) in f.soft_bands(rad) { let rr = geom.r.inflate(g as f64, g as f64); if rr.width() > 0.0 && rr.height() > 0.0 { - set_paint(ctx, &f.faded(a), fr.opts); + set_paint(ctx, &paint, fr.opts); ctx.fill_path(&shape_path(geom.kind, rr, geom.corner)); } } @@ -1103,3 +1127,6 @@ mod tests; #[cfg(test)] mod cache_tests; + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fd2c19880..82dfd96a6 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-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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; @@ -281,6 +281,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Soft edges on a see-through solid fill fade from the fill's own opacity, not from fully opaque, in the raster, PDF and caret-background paths; opaque fills draw as before (#1600). Synthetic raster/PDF tests; no new Word comparison | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle | | 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) | From 41d46625e0d3b79edc9c2cc774a1e3a19de35b15 Mon Sep 17 00:00:00 2001 From: Morzilla Date: Mon, 2 Nov 2026 16:55:20 +0100 Subject: [PATCH 20/20] DOCX: VML horizontal rules take their line (#3956) * DOCX: VML horizontal rules take their line A web page's
pasted into Word is a VML rule: `v:rect` with `o:hr="t"`, often `width:0`. WordCraft took the width as written and drew a 1 pt dot where Word draws a line across the page. Measured on Word with a synthetic document: a rule with no width and no `o:hrpct` spans the line inside the paragraph's indents; `o:hrpct` is a share of it in tenths of a percent (500 = half); `o:hrpct="0"` keeps the shape's own width; `o:hralign` places a shorter one (left, centre, right); `fillcolor` and `stroked` apply as to any VML shape. WordCraft now matches each case within 0.1 pt. - wordcraft-doc: ShapeExtra::hr (HorizontalRule: share, alignment, Word's standard look). - wordcraft-docx: `o:hr`, `o:hrpct`, `o:hralign`, `o:hrstd` read; a rule is written back as the same VML rule (DrawingML has none), and Word opens the re-saved file with the same lines. - wordcraft-layout: a rule takes its whole line and is drawn at its share; measured for a table column it asks for no width, so it follows its column. (Word draws a rule in a cell at the page's text width, centred on the cell and past its edges; it stays in the cell here.) docs/file-format-parity.md is updated. Co-Authored-By: Claude Opus 5.5 * Horizontal rules: only an inline, shown rule is one Review: the rule arm of the writer ignored the shape's placement, so a rule imported with position:absolute or visibility:hidden came back inline and visible. Only a rule in the text and shown is read as a rule now (a pasted
is one); a positioned or hidden one stays a plain shape, which keeps both when saved. The writer checks the same. Co-Authored-By: Claude Opus 5.5 * Horizontal rules: a see-through fill is kept; column measuring asks for the right width Review: `write_hr` wrote only the fill's colour, so a rule with `v:fill opacity` came back opaque after a save; the opacity now goes back as `v:fill`. Review: measured for a table column, every rule asked for no width, so one of its own width (`o:hrpct="0"`) could end up clipped by a narrow column; it now asks for its width. AutoFit to contents measures on a line of its own width that the rule took for a real line, so a rule sharing its line made the column as wide as it could go; any measuring width now counts. `Placed::Shape` boxes its extras: with the rule, `Placed` went past clippy's large_enum_variant limit. Co-Authored-By: Claude Opus 5.5 * Horizontal rules: placed in what the line has left; rotation and flips kept on save Review: a whole-line rule ignores a first-line indent or a line narrowed by a float. Measured in Word, it does too: the width stays the share of the paragraph's width and the rule runs past the margin, or under a float on its right. What did differ was the placement: Word centres and right-aligns a rule in what the line has left, with a first-line indent moving the end of that room along, and a right-aligned rule beside a float was drawn off the page here. The display now places it that way (`ParaLayout::rule_room`). Review: a save dropped a rule's rotation and flips; they now go back in its style, as Word writes them. Co-Authored-By: Claude Opus 5.5 * docs: VML horizontal-rule revision row at the top of the history --------- Co-authored-by: Daniele Mori Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/connector.rs | 33 +++++++++ crates/docx/src/read/story/vml.rs | 16 ++++ crates/docx/src/write/story/object.rs | 71 ++++++++++++++++++ crates/docx/tests/it/fixtures.rs | 59 +++++++++++++++ crates/layout/src/display/lines.rs | 2 +- crates/layout/src/display/objects.rs | 13 +++- crates/layout/src/floats.rs | 4 +- crates/layout/src/para.rs | 13 ++++ crates/layout/src/para/paragraph.rs | 11 +++ crates/layout/src/placed.rs | 2 +- crates/layout/src/table.rs | 2 +- crates/layout/src/tests.rs | 101 +++++++++++++++++++++++++- docs/file-format-parity.md | 3 +- 13 files changed, 322 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 67791967b..8f69ee35a 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -213,6 +213,39 @@ pub struct ShapeExtra { /// main colour (the shape's `stroke`). #[serde(skip_serializing_if = "Option::is_none")] pub outline_fill: Option, + /// A horizontal rule (VML `o:hr`: a web page's `
` pasted into Word): it takes its whole + /// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width. + #[serde(skip_serializing_if = "Option::is_none")] + pub hr: Option, +} + +/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one: +/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the +/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line. +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HorizontalRule { + pub pct: f32, + pub align: crate::props::Align, + /// Word's standard look (`o:hrstd`), kept for saving. + pub standard: bool, +} + +impl HorizontalRule { + /// The rule's width on a line `line` wide, for a shape `own` wide. + pub fn width(&self, line: f32, own: f32) -> f32 { + let line = line.max(0.0); + if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) } + } + + /// Where a rule `w` wide starts on a line from `x`, `line` wide. + pub fn left(&self, x: f32, line: f32, w: f32) -> f32 { + match self.align { + crate::props::Align::Center => x + (line - w) / 2.0, + crate::props::Align::Right => x + line - w, + _ => x, + } + } } fn is_zero(v: &u32) -> bool { diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index 06fccdfa9..ebb2353b6 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -295,6 +295,22 @@ impl Reader<'_> { } let kind = kind?; let mut extra = ShapeExtra { cap, ..Default::default() }; + // A horizontal rule (`o:hr`, a pasted `
`): across `o:hrpct` tenths of a percent of its + // line, or its own width (`o:hrpct="0"`); a width of 0 with no percentage is the whole + // line. `o:hralign` places a shorter one (Word, measured: 50 % centred, left, right). + // Only a rule in the text, shown: a positioned or hidden one stays a plain shape, which + // keeps its placement and visibility when saved. + if attr("o:hr").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")) && float.wrap == Wrap::Inline && !float.hidden { + let tenths = attr("o:hrpct").and_then(|v| v.trim().parse::().ok()).filter(|p| p.is_finite() && *p > 0.0); + let pct = tenths.map_or(if w > 0.0 { 0.0 } else { 100.0 }, |t| (t / 10.0).min(100.0)); + let align = match attr("o:hralign").map(|a| a.trim().to_ascii_lowercase()).as_deref() { + Some("center") => wordcraft_doc::props::Align::Center, + Some("right") => wordcraft_doc::props::Align::Right, + _ => wordcraft_doc::props::Align::Left, + }; + let standard = attr("o:hrstd").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")); + extra.hr = Some(wordcraft_doc::connector::HorizontalRule { pct, align, standard }); + } if kind == ShapeKind::Line { // Arrowheads (`v:stroke` startarrow / endarrow, with their width and length): any but // `none` is drawn. diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 340ca8fce..cee8a045c 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -115,6 +115,9 @@ impl Writer<'_> { // ink keep their original source and related parts. InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth), InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth), + InlineObject::Shape { extra, float, .. } if extra.hr.is_some() && float.wrap == Wrap::Inline && !float.hidden => { + self.write_hr(w, o, props) + } InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth), InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth), InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { @@ -277,6 +280,74 @@ impl Writer<'_> { self.rev_close(w, props); } + /// A horizontal rule goes back as the VML rule it was read from: DrawingML has none, and a + /// fixed-size shape would lose its line-wide width. + fn write_hr(&mut self, w: &mut W, o: &InlineObject, props: &CharProps) { + let InlineObject::Shape { w: sw, h: sh, fill, stroke, stroke_width, extra, float, .. } = o else { return }; + let Some(hr) = extra.hr else { return }; + let align = match hr.align { + wordcraft_doc::props::Align::Center => "center", + wordcraft_doc::props::Align::Right => "right", + _ => "left", + }; + let width = if hr.pct > 0.0 { "0".to_string() } else { format!("{:.2}pt", sw.max(0.0)) }; + let mut style = format!("width:{width};height:{:.2}pt", sh.max(0.0)); + let spin = float.spin(); + if spin.deg != 0.0 { + style.push_str(&format!(";rotation:{}", spin.deg)); + } + match (spin.flip_h, spin.flip_v) { + (true, true) => style.push_str(";flip:x y"), + (true, false) => style.push_str(";flip:x"), + (false, true) => style.push_str(";flip:y"), + (false, false) => {} + } + let pct = format!("{}", (hr.pct * 10.0).round().max(0.0) as u32); + let fill_hex = fill.as_ref().map(|f| format!("#{}", f.main_color().hex())); + let stroke_hex = stroke.map(|c| format!("#{}", c.hex())); + let weight = format!("{stroke_width:.2}pt"); + let mut attrs: Vec<(&str, &str)> = vec![ + ("xmlns:v", "urn:schemas-microsoft-com:vml"), + ("xmlns:o", "urn:schemas-microsoft-com:office:office"), + ("style", &style), + ("o:hrpct", &pct), + ("o:hralign", align), + ("o:hr", "t"), + ]; + if hr.standard { + attrs.push(("o:hrstd", "t")); + } + if let Some(f) = &fill_hex { + attrs.push(("fillcolor", f)); + } else { + attrs.push(("filled", "f")); + } + match &stroke_hex { + Some(s) => { + attrs.push(("strokecolor", s)); + attrs.push(("strokeweight", &weight)); + } + None => attrs.push(("stroked", "f")), + } + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:pict", &[]); + // A see-through fill keeps its opacity, which `fillcolor` can't say. + match fill.as_ref() { + Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) if *transparency > 0.0 => { + let opacity = format!("{:.4}", (1.0 - transparency / 100.0).clamp(0.0, 1.0)); + w.open("v:rect", &attrs); + w.empty("v:fill", &[("opacity", &opacity)]); + w.close("v:rect"); + } + _ => w.empty("v:rect", &attrs), + } + w.close("w:pict"); + w.close("w:r"); + self.rev_close(w, props); + } + fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return }; self.rev_open(w, props); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index dd5169d9a..82d506793 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -4312,3 +4312,62 @@ fn vml_textbox_style_anchor_and_fit() { assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center); assert!(tb.body.auto_fit); } + +/// A VML horizontal rule (`o:hr`, a web page's `
` pasted into Word) is read as one: the whole +/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or +/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule. +#[test] +fn vml_horizontal_rules_are_read_and_kept() { + use wordcraft_doc::connector::HorizontalRule; + use wordcraft_doc::props::Align; + let hr = |attrs: &str, style: &str| format!(r#""#); + let body = [ + hr(r#"o:hralign="center" o:hrstd="t" o:hr="t""#, "width:0;height:1.5pt"), + hr(r#"o:hrpct="500" o:hralign="right" o:hr="t""#, "width:0;height:1.5pt"), + hr(r#"o:hrpct="0" o:hralign="center" o:hr="t""#, "width:100pt;height:3pt"), + hr("", "width:100pt;height:3pt"), + ] + .concat(); + let rules = |d: &Document| -> Vec<(Option, f32)> { + paras(d) + .iter() + .filter_map(|p| p.objects.iter().find_map(|o| if let InlineObject::Shape { extra, w, .. } = o { Some((extra.hr, *w)) } else { None })) + .collect() + }; + let d = read_body(&body); + let got = rules(&d); + assert_eq!( + got, + [ + (Some(HorizontalRule { pct: 100.0, align: Align::Center, standard: true }), 0.0), + (Some(HorizontalRule { pct: 50.0, align: Align::Right, standard: false }), 0.0), + (Some(HorizontalRule { pct: 0.0, align: Align::Center, standard: false }), 100.0), + (None, 100.0), + ] + ); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(rules(&again)[..3], got[..3]); + // A rule's rotation and flips are kept. + let spin = |d: &Document| paras(d)[0].objects.iter().find_map(|o| o.frame()).map(|(_, _, f)| f.spin()); + let d = read_body(&hr(r#"o:hrpct="500" o:hr="t""#, "width:0;height:1.5pt;rotation:45;flip:x y")); + assert_eq!(spin(&d), Some(wordcraft_geom::Spin::new(45.0, true, true))); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(spin(&again), spin(&d)); + // A see-through rule stays see-through. + let fill = |d: &Document| paras(d)[0].objects.iter().find_map(|o| if let InlineObject::Shape { fill, .. } = o { fill.clone() } else { None }); + let d = read_body( + r##""##, + ); + let Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) = fill(&d) else { panic!("{:?}", fill(&d)) }; + assert!((transparency - 75.0).abs() < 0.01, "{transparency}"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(fill(&again), fill(&d)); + // A positioned or hidden rule stays a plain shape, keeping its placement and visibility. + let body = [ + hr(r#"o:hralign="center" o:hr="t""#, "position:absolute;margin-left:10pt;margin-top:20pt;width:100pt;height:2pt"), + hr(r#"o:hralign="center" o:hr="t""#, "visibility:hidden;width:0;height:2pt"), + ] + .concat(); + let d = read_body(&body); + assert_eq!(rules(&d).iter().map(|r| r.0).collect::>(), [None, None]); +} diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index 857a81aac..04e49bc41 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -583,7 +583,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { } let cx = x + line.cl_left(k).unwrap_or(0.0); let obj = para.and_then(|p| p.objects.get(oi)); - let rect = inline_rect(obj, cx, base, c.adv, c.obj_h); + let rect = inline_rect(obj, cx, base, c.adv, c.obj_h, pl.rule_room(line, k)); let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default(); match obj { Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => { diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index 9b5f3c075..7fe6d857f 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -479,10 +479,21 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp /// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the /// baseline at `cx`): inside the room kept for its effects. /// A rotated one's unrotated frame, centred in that room. -pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect { +/// +/// `line` is the room a horizontal rule is placed in, from `cx`. +pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32, line: f32) -> Rect { let frame = obj.and_then(InlineObject::frame); let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0)); + // A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its + // line, and is placed by its alignment in what the line has left: it runs past the margin + // beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`). + if let Some(InlineObject::Shape { w, extra, .. }) = obj + && let Some(hr) = extra.hr + { + let rw = hr.width(room.w, *w); + return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h); + } let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; // The room is the rotated bounds of the frame: undo the rotation. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 6a7d3551b..876b4c32a 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -258,7 +258,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR effects: *effects, freeform: freeform.clone(), spin, - extra: extra.clone(), + extra: Box::new(extra.clone()), object: Some(object), alt: shape_figure_alt(o).unwrap_or_default().to_string(), }] @@ -491,7 +491,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec f32 { + let adv = self.clusters.get(k).map_or(0.0, |c| c.adv); + if line.rtl { + return adv; + } + let first = if line.c0 == 0 { self.rp.indent_first } else { 0.0 }; + (line.right + first - line.cl_left(k).unwrap_or(0.0)).max(0.0) + } + /// Returns whether `off` is a legal caret and edit boundary, that is, not the interior start of a /// joined grapheme cluster. fn is_edit_boundary(&self, off: usize) -> bool { diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index f5fdb3c67..1a67b2fe4 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -326,6 +326,17 @@ impl<'a> Shaped<'a> { // the margin and the page edge clips it (see `ParaEnv::fit_objects`). let maxw = (env.width - cx.rp.indent_left.max(0.0) - cx.rp.indent_right.max(0.0)).max(18.0); let h = env.fitted.iter().find(|(i, _)| *i == k).map_or(saved_h, |(_, h)| *h); + // A horizontal rule takes its whole line; display draws it at its share. Measured for + // a table column (an unbounded line) a share of its line asks for no width, following + // the column rather than widening it; one of its own width asks for that. + let w = match o { + InlineObject::Shape { extra, .. } => match extra.hr { + Some(_) if env.width < crate::table::MEASURE_WIDE => maxw, + Some(hr) if hr.pct > 0.0 => 1.0, + _ => w, + }, + _ => w, + }; let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 }; // The line makes room for effects (shadows) around the picture too, and diff --git a/crates/layout/src/placed.rs b/crates/layout/src/placed.rs index cf55bccf9..e6ce2a2da 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -48,7 +48,7 @@ pub enum Placed { effects: wordcraft_doc::effects::ShapeEffects, spin: Spin, /// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]). - extra: wordcraft_doc::connector::ShapeExtra, + extra: Box, /// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving /// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]). object: Option, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 5e7904e39..d999f801d 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -145,7 +145,7 @@ pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: /// rows only, so AutoFit stays quick. const MEASURE_BUDGET: usize = 4000; /// The line length paragraphs are measured at: wide enough that nothing real wraps. -const MEASURE_WIDE: f32 = 15_840.0; +pub(crate) const MEASURE_WIDE: f32 = 15_840.0; /// What an empty cell's text needs (about a paragraph mark), points. const EMPTY_TEXT: f32 = 12.0; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a836099af..6879f5c37 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5051,7 +5051,7 @@ fn inline_picture_keeps_room_for_its_effects() { assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents"); // Drawn inside that room. let obj = picture(100.0, 50.0, shadow); - let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h); + let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h, c.adv); assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0)); } @@ -12326,6 +12326,105 @@ fn a_row_leaves_room_for_the_border_below_it() { } } +/// A horizontal rule (VML `o:hr`) takes its whole line and is drawn across its share of it, +/// placed by its alignment (Word, measured: 50 % centred, left, right). In a table cell it +/// follows its column instead of widening the table. +#[test] +fn horizontal_rules_take_their_line() { + use wordcraft_doc::connector::HorizontalRule; + use wordcraft_doc::props::Align; + let rule = |pct: f32, align: Align| { + let mut o = rect_shape(0.0, 1.5, Default::default()); + if let InlineObject::Shape { extra, .. } = &mut o { + extra.hr = Some(HorizontalRule { pct, align, standard: true }); + } + o + }; + let drawn = |d: &Document| -> Vec { + let l = lay(d); + display::page_display(d, &l.pages[0], &display::DisplayOptions::default()) + .iter() + .filter_map( + |i| if let display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } = i { Some(*rect) } else { None }, + ) + .collect() + }; + let mut d = Document::from_text("a\nb"); + d.insert_object(&Pos::body(1, 0), rule(50.0, Align::Center), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), rule(100.0, Align::Left), &Default::default()).unwrap(); + let s = drawn(&d); + let (full, half) = (s[0], s[1]); + assert!(full.w > 400.0, "the whole line: {full:?}"); + assert!((half.w - full.w / 2.0).abs() < 0.01 && (half.x - (full.x + full.w / 4.0)).abs() < 0.01, "{half:?} in {full:?}"); + let mut cell = Table::new(1, 1, 200.0); + let mut p = Paragraph::default(); + p.insert_object(0, rule(100.0, Align::Left), &Default::default()).unwrap(); + cell.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + d.body = vec![Arc::new(Block::Table(cell))]; + let s = drawn(&d); + assert!(s.first().is_some_and(|r| r.w > 150.0 && r.w < 200.0), "the cell's width, not the page's: {s:?}"); + // Word's numbers (A4, 1 inch margins, a 100 pt float): beside a float a rule keeps its width + // and is placed in what the line has left, a first-line indent moving the end of that room + // along, so a whole-line rule runs past the margin as in Word. + let beside = |pct: f32, align: Align, side: Option, first: f32| { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + let mut d = Document::from_text("a"); + if first != 0.0 { + let mut p = d.body[0].as_para().unwrap().clone(); + p.props.indent_first = Some(first); + d.body[0] = Arc::new(Block::Para(p)); + } + d.insert_object(&Pos::body(0, 0), rule(pct, align), &Default::default()).unwrap(); + if let Some(x) = side { + let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, ..Default::default() }; + d.insert_object(&Pos::body(0, 0), rect_shape(100.0, 100.0, f), &Default::default()).unwrap(); + } + let l = lay(&d); + display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()) + .iter() + .find_map(|i| match i { + display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } if rect.h < 10.0 => { + Some((rect.x, rect.x + rect.w)) + } + _ => None, + }) + .unwrap() + }; + let (x0, x1) = beside(100.0, Align::Left, None, 0.0); + let (tw, half) = (x1 - x0, (x1 - x0) / 2.0); + let near = + |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.5 && (got.1 - want.1).abs() < 0.5, "{got:?}, Word's rule {want:?}"); + // A float on the left: the room is from past it to the margin. + let lo = x0 + 109.0; + near(beside(50.0, Align::Left, Some(0.0), 0.0), (lo, lo + half)); + near(beside(50.0, Align::Center, Some(0.0), 0.0), ((lo + x1 - half) / 2.0, (lo + x1 + half) / 2.0)); + near(beside(50.0, Align::Right, Some(0.0), 0.0), (x1 - half, x1)); + near(beside(100.0, Align::Left, Some(0.0), 0.0), (lo, lo + tw)); + // A float on the right: from the margin to before it. + let hi = x1 - 109.0; + near(beside(50.0, Align::Center, Some(tw - 100.0), 0.0), ((x0 + hi - half) / 2.0, (x0 + hi + half) / 2.0)); + near(beside(50.0, Align::Right, Some(tw - 100.0), 0.0), (hi - half, hi)); + near(beside(100.0, Align::Left, Some(tw - 100.0), 0.0), (x0, x1)); + // A first-line indent moves the room along. + near(beside(100.0, Align::Left, None, 36.0), (x0 + 36.0, x1 + 36.0)); + near(beside(50.0, Align::Center, None, 36.0), (x0 + 36.0 + half / 2.0, x1 + 36.0 - half / 2.0)); + near(beside(50.0, Align::Right, None, 36.0), (x1 + 36.0 - half, x1 + 36.0)); + // Measuring a table column: a rule of its own width asks for it, a share of the line for none. + let measured = |pct: f32| { + let mut o = rect_shape(150.0, 1.5, Default::default()); + if let InlineObject::Shape { extra, .. } = &mut o { + extra.hr = Some(HorizontalRule { pct, align: Align::Left, standard: false }); + } + let mut t = Table::new(1, 1, 200.0); + let mut p = Paragraph::default(); + p.insert_object(0, o, &Default::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + crate::autofit_widths(&crate::measure_table_columns(&d, &t), 451.0)[0] + }; + assert!(measured(0.0) >= 150.0, "{}", measured(0.0)); + assert!(measured(100.0) < 150.0, "{}", measured(100.0)); +} + /// A kept-together paragraph starting a continuous section low on a page, with room there for /// one of its lines, moves whole to the next page, where it fits (#3907 review). #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 82dfd96a6..96f36cbeb 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-11-02 · **Change:** trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously 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; @@ -281,6 +281,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `
` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule | | 2026-11-02 | trivial | Soft edges on a see-through solid fill fade from the fill's own opacity, not from fully opaque, in the raster, PDF and caret-background paths; opaque fills draw as before (#1600). Synthetic raster/PDF tests; no new Word comparison | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle |