From 7c3a5ccd34ad7da99790106becc58f9f2183ad40 Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Sun, 8 Nov 2026 01:07:49 +0100 Subject: [PATCH 01/38] ODT: a table row that doesn't break across pages stays so (gap 19) (#5726) * ODT gap 19: a table row that doesn't break across pages stays so fo:keep-together on a table row wasn't read, so rows kept whole came back from a save free to break across pages in LibreOffice. Read and write it. Co-Authored-By: Claude Opus 5.5 * ODT: a row follows the default style's keep-together (gap 19) A row whose style doesn't say took "free to break" instead of the default style's setting. Found by CodeRabbit on this PR. Co-Authored-By: Claude Opus 5.5 * ODT: a row free to break stays so over a default that keeps rows together (gap 19) Nothing was written for it, and the file's default row style stays, so a save took that away. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 4 +- crates/formats/src/model/from_doc.rs | 1 + crates/formats/src/model/to_doc.rs | 1 + crates/formats/src/model/types.rs | 2 + crates/formats/src/odt/body.rs | 6 +- crates/formats/src/odt/body/tables.rs | 6 +- crates/formats/src/odt/styles.rs | 20 +++++- .../formats/src/odt/tests_more/flow_breaks.rs | 72 +++++++++++++++++++ crates/formats/src/odt/writer/tables.rs | 40 +++++++---- docs/file-format-parity.md | 3 +- 10 files changed, 137 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d02611d525..d1fb0128e5 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-06 · **Change:** trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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-06 · **Change:** trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -145,6 +145,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. +- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. @@ -821,6 +822,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. | | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | | 2026-11-05 | trivial | Progress log: ODT pictures offered in several formats (gap 19) | | 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged | | 2026-10-29 | trivial | Page drawing indexes text-box warp lookups per paragraph (#3614); requested offsets only, bounded prefix traversal, original Linux display and persistence controls; readiness estimates unchanged | diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index a3fb7fe45c..619730706a 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -1122,6 +1122,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa width: t.props.width, width_pct: t.props.width_pct, heights, + keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(), fixed: t.props.fixed, space: [t.props.space_before, t.props.space_after], header_rows: t.rows.iter().take_while(|r| r.props.header).count(), diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index d861e6c5f2..cfeee50468 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -844,6 +844,7 @@ impl<'a> Builder<'a> { row.props.height = Some(h.min(1584.0)); row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast }; } + row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)); let mut g = 0usize; for c in r.iter().take(wordcraft_doc::table::MAX_COLS) { let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1)); diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index d120ef0a6d..79c63da4c6 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -943,6 +943,8 @@ pub struct TableExtra { pub width_pct: Option, /// Each row's height (points) and whether it is exact (else at least), by row. pub heights: Vec>, + /// Whether each row keeps from breaking across pages, by row. + pub keep_rows: Vec, /// Its columns keep their widths, never fitted to their content (Word's fixed layout, /// `w:tblLayout`): an ODT table's always do. pub fixed: bool, diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 85f305f04f..fc6be0f0e7 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1391,19 +1391,23 @@ impl Body<'_> { // LibreOffice's tables keep their columns' widths, whatever their content (and however // far past the margins): Word's fixed layout. let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default(); - let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new(), fixed: true, space, header_rows: 0 }; + let extra = + model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 }; self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra }); } /// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end). fn start_row(&mut self, a: &Attrs) { let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s)); + let keep = self.styles.row_keep(get(a, "table:style-name")); if let Some(t) = self.tables.last_mut() { t.row.clear(); t.hcover = 0; if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS { t.extra.heights.truncate(t.rows.len()); t.extra.heights.push(height); + t.extra.keep_rows.truncate(t.rows.len()); + t.extra.keep_rows.push(keep); } } } diff --git a/crates/formats/src/odt/body/tables.rs b/crates/formats/src/odt/body/tables.rs index 0588a3a2e0..228ce5a248 100644 --- a/crates/formats/src/odt/body/tables.rs +++ b/crates/formats/src/odt/body/tables.rs @@ -87,7 +87,11 @@ impl TableB { let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some()); let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible())); let extra = self.extra; - let kept = extra.fixed || extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some); + let kept = extra.fixed + || extra.align.is_some() + || extra.width_pct.is_some() + || extra.heights.iter().any(Option::is_some) + || extra.keep_rows.contains(&true); FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None } } } diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 4e86c6a759..aff6237b40 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -252,6 +252,8 @@ struct StyleInfo { /// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact) /// or least height (`style:min-row-height`), points. row_height: Option<(f32, bool)>, + /// `fo:keep-together` of a table-row style: the row doesn't break across pages. + row_keep: Option, /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. master_page: Option, @@ -623,6 +625,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool { } else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) { s.row_height = Some((h.min(MAX_LEN), false)); } + if let Some(k) = get(a, "fo:keep-together") { + s.row_keep = Some(k.trim() == "always"); + } } "table-column-properties" => { if let Some(w) = get(a, "style:column-width").and_then(length) { @@ -1043,6 +1048,8 @@ pub(super) struct Styles { default_color: Option, /// `style:default-style` for graphics: what every drawing shape's style falls back on. default_graphic: super::frames::Graphic, + /// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say. + default_row_keep: Option, /// The named dashes and gradients graphic styles use. pub(super) defs: super::strokes::Defs, /// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures. @@ -1055,6 +1062,7 @@ impl Styles { let mut cur: Option<(String, StyleInfo)> = None; let mut in_default = false; let mut in_graphic_default = false; + let mut in_row_default = false; let mut dates = DateStyles::default(); let mut list: Option<(String, Vec)> = None; // The list or outline level being read. @@ -1083,8 +1091,12 @@ impl Styles { "default-style" if q.starts_with("style:") => { in_default = get(&a, "style:family") == Some("paragraph"); in_graphic_default = get(&a, "style:family") == Some("graphic"); + in_row_default = get(&a, "style:family") == Some("table-row"); } "graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a), + "table-row-properties" if in_row_default && cur.is_none() => { + self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always") + } "text-properties" if in_default && cur.is_none() => { self.default_font = default_font(&a, &self.faces); if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) { @@ -1151,7 +1163,7 @@ impl Styles { }, Ev::End(q) => match local(&q) { "automatic-styles" => in_auto = false, - "default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false), + "default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false), "style" if q.starts_with("style:") => { if let Some((n, s)) = cur.take() && self.map.len() < 100_000 @@ -1355,6 +1367,12 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.row_height) } + /// Whether a table row doesn't break across pages: its style says (whole chain), else the + /// default style. + pub(super) fn row_keep(&self, name: Option<&str>) -> bool { + name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.row_keep)).or(self.default_row_keep).unwrap_or(false) + } + /// Background colour of a table-cell style (whole chain). /// A table-cell style's background picture: its package path, repeat and position. pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> { diff --git a/crates/formats/src/odt/tests_more/flow_breaks.rs b/crates/formats/src/odt/tests_more/flow_breaks.rs index 8168fcc90b..7e5b8876ff 100644 --- a/crates/formats/src/odt/tests_more/flow_breaks.rs +++ b/crates/formats/src/odt/tests_more/flow_breaks.rs @@ -159,3 +159,75 @@ fn a_table_starting_a_page_starts_it() { let c = content_of(&crate::odt::tests::export_model(&d).unwrap()); assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}"); } + +#[test] +fn a_row_that_keeps_together_says_so() { + // Table › Text Flow: a row not to break across pages. It came back from a save free to break. + let styles = r#""#; + let row = |s: &str, t: &str| format!(r#"{t}"#); + let table = format!( + "{}{}{}", + row(r#" table:style-name="R""#, "kept"), + row("", "free"), + row(r#" table:style-name="H""#, "kept and tall") + ); + let shape = |d: &wordcraft_doc::Document| { + d.body + .iter() + .find_map(|b| b.as_table()) + .map(|t| t.rows.iter().map(|r| (r.props.cant_split, r.props.height)).collect::>()) + .unwrap_or_default() + }; + let d = doc(styles, &table); + assert_eq!(shape(&d), [(true, None), (false, None), (true, Some(72.0))]); + let saved = crate::odt::tests::export_model(&d).unwrap(); + assert!(content_of(&saved).contains(r#"fo:keep-together="always""#)); + assert_eq!(shape(&import(&saved).unwrap()), [(true, None), (false, None), (true, Some(72.0))]); +} + +/// A row whose style says nothing of it, or a row with no style, follows the default style; one +/// that says otherwise doesn't. +#[test] +fn a_rows_keep_together_defaults_to_the_default_style() { + let styles = r#""#; + let row = |s: &str| format!(r#"x"#); + let content = format!( + r#"{}{}{}"#, + row(""), + row(r#" table:style-name="H""#), + row(r#" table:style-name="F""#) + ); + let keeps = |d: &wordcraft_doc::Document| { + d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::>()).unwrap_or_default() + }; + let d = import(&package(&content, Some(styles))).unwrap(); + assert_eq!(keeps(&d), [true, true, false]); + assert_eq!(keeps(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), [true, true, false]); +} + +/// A row free to break under a default row style that keeps rows together says so when written from +/// the model, or a save takes that away: the file's default style stays. +#[test] +fn a_row_free_to_break_stays_so_over_the_default_style() { + let styles = r#""#; + let row = |s: &str| format!(r#"x"#); + let content = format!( + r#"{}{}"#, + row(r#" table:style-name="F""#), + row("") + ); + let mut d = import(&package(&content, Some(styles))).unwrap(); + let keeps = |d: &wordcraft_doc::Document| { + d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::>()).unwrap_or_default() + }; + assert_eq!(keeps(&d), [false, true]); + if let Some(wordcraft_doc::Block::Table(t)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) + && let Some(wordcraft_doc::Block::Para(p)) = t.rows[0].cells[0].blocks.get_mut(0).map(std::sync::Arc::make_mut) + { + p.text.push('!'); + if let Some(r) = p.runs.last_mut() { + r.len += 1; + } + } + assert_eq!(keeps(&import(&export(&d).unwrap()).unwrap()), [false, true], "after an edit and a save"); +} diff --git a/crates/formats/src/odt/writer/tables.rs b/crates/formats/src/odt/writer/tables.rs index 7fbb542e73..e74f55afb1 100644 --- a/crates/formats/src/odt/writer/tables.rs +++ b/crates/formats/src/odt/writer/tables.rs @@ -161,19 +161,33 @@ impl Writer { out.push_str(""); } // A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`). - match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) { - Some((h, exact)) => { - let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) }; - let k = match self.row_styles.iter().position(|s| *s == props) { - Some(k) => k, - None => { - self.row_styles.push(props); - self.row_styles.len() - 1 - } - }; - out.push_str(&format!("", self.prefix, k + 1)); - } - None => out.push_str(""), + let height = match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) { + Some((h, true)) => format!(" style:row-height=\"{}\"", pt(h)), + Some((h, false)) => format!(" style:min-row-height=\"{}\"", pt(h)), + None => String::new(), + }; + // A row that doesn't break across pages (§20.247 on a row). One that may says so where + // the file's default row style, which stays, would keep it together. + let keep = if t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)) { + " fo:keep-together=\"always\"" + } else if self.kept_defs.get("common~style:default-style~table-row").is_some_and(|d| d.contains("keep-together") && d.contains("always")) + { + " fo:keep-together=\"auto\"" + } else { + "" + }; + let props = format!("{height}{keep}"); + if props.is_empty() { + out.push_str(""); + } else { + let k = match self.row_styles.iter().position(|s| *s == props) { + Some(k) => k, + None => { + self.row_styles.push(props); + self.row_styles.len() - 1 + } + }; + out.push_str(&format!("", self.prefix, k + 1)); } let mut g = 0usize; for c in row { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f71498570d..5f74237e43 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,7 +1,7 @@ # File-format parity with Microsoft Word > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the @@ -321,6 +321,7 @@ CSL and library import are separate work. | Date | Change | Summary | |---|---|---| | 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. | +| 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. | | 2026-11-05 | trivial | ODT: a frame offering one picture in several formats (LibreOffice's `.svm` metafile, then a PNG) shows the first WordCraft can read; it took the first whatever it was, and the picture was missing on screen and gone once the paragraph was rewritten An object's replacement picture coming before it is kept too. A readable picture after an unreadable replacement is shown, wherever the object comes, and a save from the model keeps both. | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | From e0dd821d4c70eb799c9c39eb1c92cdcfbaa6f532 Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:31:33 +0100 Subject: [PATCH 02/38] Release: refresh contributor credits for each release (#5844) (#5845) About > Contributors still showed an old snapshot, because nothing regenerated contributors/contributors.json before a release. Opt in to the shared refresh from craft-common-actions: a read-only credits job regenerates the snapshot for the release commit, and the shared release workflow installs it before every source build. The snapshot is not committed back. --- .github/workflows/release.yml | 8 ++++++++ docs/contributors.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2951acfc3a..89d3b647b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,14 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main release: + needs: credits # Only this job publishes the GitHub release, so only it gets write access. permissions: contents: write @@ -31,6 +38,7 @@ jobs: craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/docs/contributors.md b/docs/contributors.md index 6f26d0da64..6d7300df42 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers. - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. + username. Release builds regenerate it for the release commit + (`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced From 3b6547de8f1227fcd3f5a7ab578da2e5765cb493 Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Sun, 8 Nov 2026 01:36:03 +0100 Subject: [PATCH 03/38] ODT: watermark and odd/even page changes to a file are saved (gap 19) (#5729) * ODT gap 19: a watermark change to a file is saved A file's own page definitions were written back whatever happened to its watermark, so one added, changed or removed in WordCraft was gone from the saved file. The watermark now counts as part of the page setup, so a change writes the page definitions from the model. A watermark with no text isn't written, so it isn't a change. Co-Authored-By: Claude Opus 5.5 * ODT gap 19: Different Odd & Even Pages changed in a file is saved A file's own page definitions were written back whatever happened to this setting, so the left pages' header stayed after it was turned off, and turning it on did nothing. The setting now counts as part of the page setup, as the watermark does. Co-Authored-By: Claude Opus 5.5 * ODT tests: say what the watermark and odd/even tests check (gap 19) Doc comments for CodeRabbit's docstring coverage check on this PR. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 4 +- crates/doc/src/kept.rs | 7 +- crates/formats/src/odt/tests_more/headers.rs | 91 ++++++++++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d1fb0128e5..1d32c72648 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-06 · **Change:** trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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-06 · **Change:** trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -146,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. +- 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. @@ -823,6 +824,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | | 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | +| 2026-11-05 | trivial | Progress log: a watermark or odd and even headers change to a file is saved | | 2026-11-05 | trivial | Progress log: ODT pictures offered in several formats (gap 19) | | 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged | | 2026-10-29 | trivial | Page drawing indexes text-box warp lookups per paragraph (#3614); requested offsets only, bounded prefix traversal, original Linux display and persistence controls; readiness estimates unchanged | diff --git a/crates/doc/src/kept.rs b/crates/doc/src/kept.rs index ee0d3e9a1a..4f9437972f 100644 --- a/crates/doc/src/kept.rs +++ b/crates/doc/src/kept.rs @@ -478,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap u64 { let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect(); sections.push(&doc.last_section); @@ -490,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 { match serde_json::to_vec(&( sections, parts, + s.even_odd_headers, &s.page_color, &s.page_picture, + s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()), (&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr), (&doc.footnote_separators, &doc.endnote_separators), )) { diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs index 57ff01970a..80478447d4 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -287,6 +287,97 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() { ); } +/// A watermark added, changed or removed in WordCraft reaches a file's page definitions. +#[test] +fn a_watermark_change_to_a_file_is_saved() { + // A file's own page definitions were written back whatever happened to its watermark, so one + // added, changed or removed in WordCraft was gone from the saved file. + let ns = crate::odt::tests::NS_T; + let styles = format!( + r#""# + ); + let content = + format!("x"); + let plain = crate::odt::tests::package(&content, Some(&styles)); + let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml"); + let shape = "PowerPlusWaterMarkObject"; + + // Added. + let mut d = import(&plain).unwrap(); + d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), ..Default::default() }); + let saved = export(&d).unwrap(); + let s = crate::odt::tests::entry(&saved, "styles.xml"); + assert!(s.contains(shape) && s.contains("DRAFT"), "added: {s}"); + assert_eq!(import(&saved).unwrap().settings.watermark.map(|w| w.text).as_deref(), Some("DRAFT")); + + // Saved again with nothing changed, it stays as the file has it. + let read = import(&saved).unwrap(); + let again = styles_of(&read); + assert_eq!(again.matches(shape).count(), s.matches(shape).count(), "{again}"); + + // Changed. + let mut d = import(&saved).unwrap(); + if let Some(w) = d.settings.watermark.as_mut() { + w.text = "COPY".into(); + } + let s = styles_of(&d); + assert!(s.contains("COPY") && !s.contains("DRAFT"), "changed: {s}"); + + // Removed. + let mut d = import(&saved).unwrap(); + d.settings.watermark = None; + let s = styles_of(&d); + assert!(!s.contains(shape) && !s.contains("DRAFT"), "removed: {s}"); + + // One with no text isn't written, so it's no change to the file's page definitions. + let mut d = import(&plain).unwrap(); + d.settings.watermark = Some(wordcraft_doc::Watermark { text: " ".into(), ..Default::default() }); + let s = styles_of(&d); + assert!(s.contains(r#"style:page-usage="mirrored""#) && !s.contains(shape), "no text: {s}"); +} + +/// Different Odd & Even Pages turned on or off in WordCraft reaches a file's page definitions. +#[test] +fn different_odd_and_even_pages_changed_in_a_file_is_saved() { + // A file's own page definitions were written back whatever happened to this setting, so the + // left pages' header stayed after it was turned off. + let ns = crate::odt::tests::NS_T; + let file = |left: &str| { + let styles = format!( + r#"Right head{left}"# + ); + let content = format!( + "x" + ); + crate::odt::tests::package(&content, Some(&styles)) + }; + let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml"); + + // On in the file, and nothing changed: the file's own. + let both = file("Left head"); + let d = import(&both).unwrap(); + assert!(d.settings.even_odd_headers); + let s = styles_of(&d); + assert!(s.contains(r#"style:page-usage="mirrored""#) && s.contains("Left head"), "as read: {s}"); + + // Turned off. + let mut d = import(&both).unwrap(); + d.settings.even_odd_headers = false; + let saved = export(&d).unwrap(); + let s = crate::odt::tests::entry(&saved, "styles.xml"); + assert!(!s.contains(" **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the @@ -322,6 +322,7 @@ CSL and library import are separate work. |---|---|---| | 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. | | 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. | +| 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. | | 2026-11-05 | trivial | ODT: a frame offering one picture in several formats (LibreOffice's `.svm` metafile, then a PNG) shows the first WordCraft can read; it took the first whatever it was, and the picture was missing on screen and gone once the paragraph was rewritten An object's replacement picture coming before it is kept too. A readable picture after an unreadable replacement is shown, wherever the object comes, and a save from the model keeps both. | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | From 7aea5f8cab616b6b0f9f6c69287434996dc1e699 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:48:51 +0500 Subject: [PATCH 04/38] Numbering: Thai numbers are drawn in Thai digits (#5784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list level, page number or note mark in Word's thaiNumbers format was read and saved as such but drawn as decimal (1, 2, 3). It now shows the decimal number in Thai digits (U+0E50..U+0E59): ๑, ๒ … ๑๐, with the level's text around it (๑., (๒)). Thai letters and Thai counting still show decimal until they are drawn too. Part of #5757 (Thai numerals in automatic numbering). --- crates/doc/src/section.rs | 25 +++++++++++++++++++++---- crates/engine/src/cmd/lists.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 1d72468f6f..0918cb961c 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -289,13 +289,14 @@ impl NumFormat { pub fn from_ooxml(s: &str) -> NumFormat { NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal) } - /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai, - /// Vietnamese, text amounts) show decimal, as do values past a sequence's end - /// (the 21st item of ①…⑳). + /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters + /// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end + /// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐). pub fn format(self, n: u32) -> String { use crate::cjk_numbers as cjk; match self { NumFormat::DecimalZero => format!("{n:02}"), + NumFormat::ThaiNumbers => thai_digits(n), NumFormat::UpperRoman => roman(n).to_uppercase(), NumFormat::LowerRoman => roman(n), NumFormat::UpperLetter => letters(n).to_uppercase(), @@ -349,7 +350,6 @@ impl NumFormat { | NumFormat::HindiNumbers | NumFormat::HindiCounting | NumFormat::ThaiLetters - | NumFormat::ThaiNumbers | NumFormat::ThaiCounting | NumFormat::BahtText | NumFormat::DollarText => n.to_string(), @@ -357,6 +357,11 @@ impl NumFormat { } } +/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757). +fn thai_digits(n: u32) -> String { + n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect() +} + /// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on. fn chicago(n: u32) -> String { const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}']; @@ -999,6 +1004,18 @@ mod tests { assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal"); } + /// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length. + #[test] + fn thai_numbers_use_thai_digits() { + let labels: Vec = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect(); + assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙"); + assert_eq!(NumFormat::ThaiNumbers.format(0), "๐"); + assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕"); + assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers); + // Formats not drawn yet still show decimal. + assert_eq!(NumFormat::ThaiLetters.format(3), "3"); + } + #[test] fn russian_letters_follow_word() { // Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters. diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 1338eeb5e1..5fa185324c 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -452,6 +452,36 @@ mod tests { assert_eq!(numbered(&mut b), want); } + /// #5757: a list numbered in Thai digits shows them on the page, with the level's text around + /// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back. + #[test] + fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() { + let mut s = Session::new(Document::new()); + for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() { + if i > 0 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": t})).unwrap(); + } + s.run("select.all", &json!({})).unwrap(); + let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]); + s.run("list.define", &json!({"levels": levels})).unwrap(); + let page: Vec> = numbered(&mut s).into_iter().map(|x| x.0).collect(); + let want: Vec> = + ["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect(); + assert_eq!(page, want); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let mut b = Session::new(back); + let again: Vec> = numbered(&mut b).into_iter().map(|x| x.0).collect(); + assert_eq!(again, want, "thaiNumbers survives .docx"); + // In parentheses too. + let mut s = Session::new(Document::new()); + s.run("text.insert", &json!({"text": "x"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap(); + assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)")); + } + /// #1793: the other heading presets of Word's List Library, level by level. #[test] fn every_heading_preset_numbers_three_heading_levels() { From e7668ae53e2abb16fdeded8b4cf363c9038b99d7 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:48:57 +0100 Subject: [PATCH 05/38] Tests: run the file commands through the hostile-parameter test (#3719) (#5688) * #3719: run the file commands through the hostile-parameter test hostile_params_never_panic skips file.*, insert.picture and insert.textFromFile, the commands that take a path and parse what is there. hostile_file_params_never_panic runs them with the same junk params, and with paths to an empty file, a folder, a missing name and noise under each extension they open or save. Automation roots hold them to a temporary folder. It found no panic, so this adds only the test. Co-Authored-By: Claude Opus 5.5 * #5688: check the whole working directory, on the sample document The working-directory check looked only for hostile-relative names, but an empty path comes out as .pdf or .dotx there. It now compares the whole folder before and after the runs. The runs now use the sample document, like hostile_params_never_panic: on an empty document the export commands had almost nothing to write. The docs and comments name the three groups of commands, because other commands also read files. Co-Authored-By: Claude Opus 5.5 * #5688: a failing file check names the files, not their bytes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 147 +++++++++++++++++++++++++++++++++++-- docs/architecture.md | 8 +- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e13cf934b7..316300fca9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4681,10 +4681,8 @@ fn typed_paragraphs_are_capped_for_every_caller() { assert!(!body_texts(&s).iter().any(|p| p.contains('y'))); } -#[test] -fn hostile_params_never_panic() { - let reg = cmd::registry(); - let junk = [ +fn hostile_junk() -> [serde_json::Value; 8] { + [ json!(null), json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), @@ -4693,11 +4691,23 @@ fn hostile_params_never_panic() { json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}), json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}), json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}), - ]; + ] +} + +/// `file.*`, `insert.picture` and `insert.textFromFile`: `hostile_file_params_never_panic` runs +/// them, held to a folder of its own, and `hostile_params_never_panic` every other command. +fn reaches_files(id: &str) -> bool { + id.starts_with("file.") || id == "insert.picture" || id == "insert.textFromFile" +} + +#[test] +fn hostile_params_never_panic() { + let reg = cmd::registry(); + let junk = hostile_junk(); for spec in reg.all() { - // These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`) - // is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below. - if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" { + // Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its + // backend is the silent `Hold`, asserted below. + if reaches_files(spec.id) { continue; } for j in &junk { @@ -4711,6 +4721,127 @@ fn hostile_params_never_panic() { } } +/// `file.*`, `insert.picture` and `insert.textFromFile` take junk params and paths to files they +/// can't use (#3719). They may read only `in` and write only `out`, so a save can't replace a file +/// another run is meant to find broken, and nothing outside those folders is touched. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn hostile_file_params_never_panic() { + /// What is directly in `dir`: each name with its bytes (`None` for a folder). + fn contents(dir: &std::path::Path) -> std::collections::BTreeMap>> { + let entries = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()); + entries.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).ok())).collect() + } + /// The names whose bytes differ, so a failure lists them instead of printing every file. + fn changed(before: &std::collections::BTreeMap>>, dir: &std::path::Path) -> Vec { + let after = contents(dir); + let names: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect(); + names.into_iter().filter(|n| before.get(*n) != after.get(*n)).cloned().collect() + } + let reg = cmd::registry(); + let base = scratch_dir("hostile-files"); + let (input, output, outside) = (base.join("in"), base.join("out"), base.join("outside")); + for dir in [&outside, &input.join("folder.docx"), &output.join("folder.docx")] { + std::fs::create_dir_all(dir).unwrap(); + } + let at = |dir: &std::path::Path, name: &str| dir.join(name).to_string_lossy().into_owned(); + + // Fixed bytes, so a failure comes back on the next run. + let noise: Vec = (0..512u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect(); + let picture_exts = ["png", "jpg", "gif", "bmp", "svg", "emf"]; + let mut paths = vec![]; + for ext in crate::io::OPEN_EXTS.iter().chain(&picture_exts) { + let name = format!("random.{ext}"); + std::fs::write(input.join(&name), &noise).unwrap(); + paths.push(at(&input, &name)); + } + paths.extend(crate::io::SAVE_EXTS.iter().map(|ext| at(&output, &format!("new.{ext}")))); + std::fs::write(input.join("empty"), b"").unwrap(); + std::fs::write(input.join("empty.docx"), b"").unwrap(); + std::fs::write(input.join("good.txt"), "Good text").unwrap(); + std::fs::write(input.join("good.png"), tiny_png()).unwrap(); + paths.extend(["empty", "empty.docx", "folder.docx", "missing", "missing.docx"].map(|name| at(&input, name))); + paths.extend(["folder.docx", "missing/new.docx", &format!("{}.docx", "x".repeat(5000))].map(|name| at(&output, name))); + // Outside the roots, however it is spelled, and not paths at all. + paths.extend(["new.docx", "new"].map(|name| at(&outside, name))); + paths.push(format!("{}/../outside/new.docx", input.to_string_lossy())); + paths.extend(["hostile-relative.docx", "hostile-relative", "", "\0", "a\0b.docx"].map(String::from)); + + // Every path alone; each of these also with every other kind of junk param. The numbers that + // cost time (`copies`, `scale`) stay small: huge ones are #3720. + let mut params: Vec = hostile_junk().into(); + params.extend(paths.iter().map(|p| json!({"path": p}))); + for name in [ + "in/random.docx", + "in/empty.docx", + "in/folder.docx", + "in/missing.docx", + "in/good.txt", + "in/good.png", + "out/new.docx", + "out/new.pdf", + "out/new.png", + ] { + let path = at(&base, name); + params.extend([ + json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}), + json!({"path": path, "password": 5, "format": 5, "markup": true}), + json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}), + json!({"path": path, "data": "not base64!"}), + json!({"path": path, "data": "AAAA"}), + json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}), + json!({"dir": path, "name": path, "template": path, "userTemplatesFolder": path, "id": 1e30, "restore": 1e30, "save": path}), + ]); + } + + let before = contents(&input); + let working_dir = std::path::Path::new("."); + let working_dir_before = contents(working_dir); + let roots = crate::file_access::AutomationRoots::new(Some(&input), Some(&output)).unwrap(); + let confined = |s: &mut Session, id: &str, v: &serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(id, v)); + for spec in reg.all() { + if !reaches_files(spec.id) { + continue; + } + for v in ¶ms { + let mut s = Session::new(crate::sample::sample_document()); + let r = confined(&mut s, spec.id, v); + assert!(!caught_panic(®, &r), "{} panicked on {v}: {r:?}", spec.id); + s.clamp_selection(); + let _ = s.layout(); + } + } + + let nothing = Vec::::new(); + assert_eq!(changed(&before, &input), nothing, "the read root is only read"); + assert_eq!(changed(&Default::default(), &outside), nothing, "nothing outside the write root"); + // Every name counts: an empty path comes out as `.pdf` or `.dotx` here. + assert_eq!(changed(&working_dir_before, working_dir), nothing, "nothing in the working directory"); + + // The junk above met the files, not a closed door: usable files work through the same roots... + let mut s = s(); + confined(&mut s, "file.open", &json!({"path": at(&input, "good.txt")})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Good text"); + confined(&mut s, "insert.textFromFile", &json!({"path": at(&input, "good.txt")})).unwrap(); + confined(&mut s, "insert.picture", &json!({"path": at(&input, "good.png")})).unwrap(); + let saved: [(&str, &str, &[u8]); 3] = + [("file.saveAs", "control.docx", b"PK\x03\x04"), ("file.exportPdf", "control.pdf", b"%PDF-"), ("file.exportPng", "control.png", b"\x89PNG")]; + for (id, name, signature) in saved { + confined(&mut s, id, &json!({"path": at(&output, name)})).unwrap(); + assert_eq!(std::fs::read(output.join(name)).unwrap().get(..signature.len()), Some(signature), "{name}"); + } + // ...the noise is refused by the reader, and only a path outside by the roots. + let refused = |s: &mut Session, path: String| match confined(s, "file.open", &json!({"path": path})) { + Err(crate::CmdError::Failed(m)) => m, + r => panic!("not refused: {r:?}"), + }; + let unreadable = refused(&mut s, at(&input, "random.docx")); + assert!(!unreadable.contains("automation path rejected"), "{unreadable}"); + let rejected = refused(&mut s, at(&outside, "new.docx")); + assert!(rejected.contains("automation path rejected"), "{rejected}"); + let _ = std::fs::remove_dir_all(&base); +} + /// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a /// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes /// those words. diff --git a/docs/architecture.md b/docs/architecture.md index b937424d48..e22d36a891 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # WordCraft architecture -> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-02 · **Change:** trivial (craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-05 · **Change:** trivial (the hostile-parameter test now has a sibling for `file.*`, `insert.picture` and `insert.textFromFile`; previously trivial: craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft is built today. The longer design notes in `plan/architecture.md` are gitignored and local to the original author's machine; this file is the @@ -57,8 +57,9 @@ crate name links to its source tree. - 821 `#[test]` functions and 10 property-test blocks: model, layout, DOCX round-trip and hostile input, `.doc`, formats, typing parity (`engine/src/tests/typing.rs`), bidi, i18n catalogs, UI - interaction (egui_kittest), MCP acceptance; `hostile_params_never_panic` runs every command with - junk params. + interaction (egui_kittest), MCP acceptance; `hostile_file_params_never_panic` runs `file.*`, + `insert.picture` and `insert.textFromFile` with junk params and unusable paths, held to a + temporary folder, and `hostile_params_never_panic` every other command with junk params. - `cargo xtask ci`: fmt, clippy `-D warnings`, tests, asset attribution, layering, wasm build. - `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md); a test keeps the catalog percentage above a floor that only rises. @@ -69,6 +70,7 @@ crate name links to its source tree. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | trivial | `hostile_file_params_never_panic` runs `file.*`, `insert.picture` and `insert.textFromFile` with junk params and unusable paths (#3719) | | 2026-11-02 | trivial | craft-proof's row: without the Language data English spelling falls back to an installed Hunspell English or the built-in Moby list, and only pattern hyphenation is off (#5202 review) | | 2026-11-01 | minor | `proof` left the workspace: it is `craft-proof` in storytold/craft-libs (with its history), and the spelling and hyphenation data is craft-i18n's `language-data/` (`CRAFT_I18N_DIR`); `assets/spelling` and `assets/hyphenation` are gone | | 2026-10-28 | major | Recounted workspace crates, layers and Rust source lines; refreshed proofing languages and documented current engine/layout module boundaries and source links | From b29f3a8a2ee1210ef847b340cf563288740b756b Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:50:43 +0500 Subject: [PATCH 06/38] Save As suggests a new document's first words as its name (#5795) Saving a document for the first time suggested Document1.docx whatever it held. As in Word, a document that was never saved and has no title of its own is now offered the first words of its first paragraph with text: up to the first sentence or clause end, without the characters a file name can't hold, white space collapsed, at most 50 characters cut at a word's end (Minutes of the board meeting.docx). A path, a file name or a Title property still name it, and the window title stays Document1. Fixes #5793 --- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/files.rs | 40 +++++++++++++++++-- crates/ui-egui/src/file_dialogs/tests.rs | 3 +- crates/ui-egui/src/tests.rs | 49 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5dcd82297c..f496df68a3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,8 @@ use super::{ mod commands; mod control; mod files; +#[cfg(test)] +pub(crate) use files::suggested_stem; mod fonts; mod frame; mod input; diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index c545d131f4..64c38ee683 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -298,12 +298,16 @@ impl WordApp { } /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), - /// otherwise .docx. + /// otherwise .docx. A document never saved and without a title of its own is named after its + /// first words, as Word suggests them (#5793), instead of `Document1`. pub(crate) fn default_save_name(&self) -> String { let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string()); + let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty(); + let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t)); + let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem()); match ext { - Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()), - _ => format!("{}.docx", self.title_stem()), + Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"), + _ => format!("{stem}.docx"), } } @@ -463,3 +467,33 @@ impl WordApp { } } } + +/// The most characters a name taken from the text keeps ([`suggested_stem`]). +const SUGGESTED_MAX: usize = 50; + +/// A file name from a paragraph's text (#5793): up to its first sentence or clause end +/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a +/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most +/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows +/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used. +pub(crate) fn suggested_stem(text: &str) -> Option { + let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':'); + let head = text.split(end).next().unwrap_or(""); + let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect(); + let mut words = String::new(); + for w in kept.split_whitespace() { + let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() }; + if next > SUGGESTED_MAX { + if words.is_empty() { + words = w.chars().take(SUGGESTED_MAX).collect(); + } + break; + } + if !words.is_empty() { + words.push(' '); + } + words.push_str(w); + } + let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string(); + (!words.is_empty()).then_some(words) +} diff --git a/crates/ui-egui/src/file_dialogs/tests.rs b/crates/ui-egui/src/file_dialogs/tests.rs index 8acb162d6e..837cb96a87 100644 --- a/crates/ui-egui/src/file_dialogs/tests.rs +++ b/crates/ui-egui/src/file_dialogs/tests.rs @@ -315,7 +315,8 @@ fn save_as_answer_arriving_later_is_saved() { typed(&mut a); a.save_as_dialog(); assert!(a.file_dialog_open()); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }); + // A new document is offered its first words as its name, as in Word (#5793). + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{UNSAVED}.docx"), dir: None }); let ctx = egui::Context::default(); let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); frame(&mut a); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 8692b662a3..c8103b6e6b 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -96,6 +96,55 @@ fn title_stem_omits_nul_from_each_title_source() { } } +/// #5793: a name from a paragraph's first words, as Word suggests one: up to the first sentence or +/// clause end, without what a file name can't hold, at most 50 characters at a word's end. +#[test] +fn a_suggested_name_is_the_first_words_of_the_text() { + use crate::app::suggested_stem as stem; + assert_eq!(stem("Quarterly report for the board. More follows here.").as_deref(), Some("Quarterly report for the board")); + assert_eq!(stem("Minutes of the meeting, 6 November").as_deref(), Some("Minutes of the meeting, 6 November")); + assert_eq!(stem("What happened? Nobody knows").as_deref(), Some("What happened")); + assert_eq!(stem("Plan: phase one").as_deref(), Some("Plan")); + assert_eq!(stem("Q3 / Q4 \"draft\" *v2* | a\\b").as_deref(), Some("Q3 Q4 draft final v2 a b")); + assert_eq!(stem(" Leading and inner spaces ").as_deref(), Some("Leading and inner spaces")); + assert_eq!(stem("日本語の文書。続きの文").as_deref(), Some("日本語の文書")); + let long = "Annual report of the regional committee on transport and infrastructure planning"; + let got = stem(long).unwrap(); + assert!(got.chars().count() <= 50 && long.starts_with(&got) && long[got.len()..].starts_with(' '), "{got:?}"); + assert_eq!(stem(&"x".repeat(80)).map(|s| s.chars().count()), Some(50), "one long word is cut at 50"); + for nothing in ["", " ", "...", "?", "/ * |", "\u{1}"] { + assert_eq!(stem(nothing), None, "{nothing:?}"); + } +} + +/// #5793: Save As suggests the first words for a document that was never saved and has no title +/// of its own (the first paragraph with text, past empty ones); a title, a file name or a path +/// still name it. +#[test] +fn save_as_suggests_the_first_words_of_a_new_document() { + let mut app = app(); + assert_eq!(app.default_save_name(), format!("{}.docx", app.title_stem()), "an empty document"); + for c in [ + json!(["text.newParagraph", {}]), + json!(["text.insert", {"text": "Minutes of the board meeting. Present: all"}]), + json!(["text.newParagraph", {}]), + json!(["text.insert", {"text": "Second paragraph"}]), + ] { + app.session.run(c[0].as_str().unwrap(), &c[1]).unwrap(); + } + assert_eq!(app.default_save_name(), "Minutes of the board meeting.docx"); + assert_eq!( + app.title_stem(), + crate::documents::untitled_title(&app.session.resolved_filenames().0).unwrap_or_else(|| "Document1".into()), + "the window title stays Document1" + ); + app.session.doc.core.title = "Board minutes".into(); + assert_eq!(app.default_save_name(), "Board minutes.docx", "a title wins"); + app.session.doc.core.title.clear(); + app.session.path = Some(std::path::PathBuf::from("/tmp/kept.docm")); + assert_eq!(app.default_save_name(), "kept.docm", "a saved file keeps its name and format"); +} + /// #1678: Focus and Full Screen View put the window in full screen, asked of the system once /// per change (switching between the two keeps it), and the usual window comes back after. #[test] From fa59ed0e8a6f271f6b5b9f1b14176f2e167a70e1 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:51:01 +0100 Subject: [PATCH 07/38] fix(ui): keep Page Setup fields bounded after long paste (#5696) * M14: keep Page Setup margin and grid editors within their width (#5650) Use the pinned egui DragValue clipping option for four margins and both grid counts. Canonical actual WordApp regression and 12 Page Setup controls pass; strict all-target UI Clippy and scoped rustfmt pass. * M14: keep Page Setup paper-size editors within their width (#5650) Apply the same bounded text editing to paper width and height while preserving their existing limits, units and speed. Actual Page Setup regression and existing controls pass. * M14: regress Page Setup paste, blur and reopen widths (#5650) Exercise all eight numeric fields through the actual WordApp harness. Baseline Bottom paste reproduced width growth from 354 to 2973 pt; the fixed canonical 12-test Page Setup suite passes. Private rendering hooks are excluded. * M14: record bounded Page Setup number editors (#5650) Add the focused UI fix to the progress and revision logs and status line. Readiness estimates remain unchanged. * M14: note the focused Page Setup paste fix in gaps (#5650) Record bounded numeric editing in the gap note, status and revision history; no ranked gap or readiness estimate changes. * M14: document bounded Page Setup numeric input (#5650) Update the UI parity note, status and revision history for long input scrolling inside the existing fields. Readiness estimates remain unchanged. * M14: bound unfocused Page Setup margin values (#5650) Keep the unfocused DragValue inside an allocated, clipped field without changing numeric parsing, units, speed or accepted values. Regress a finite long paste followed by Tab in all four margins. The actual baseline grew from 354 to 458.53125 pt after Tab; the canonical 13 Page Setup tests and scoped formatting checks pass, and the inspected fixed renders stay 354 pt. Hosted validation remains pending in draft PR #5696. * M14: record unfocused Page Setup bounds (#5650) Update the gap and UI parity notes for the accepted-value paste followed by Tab regression. Retain the existing progress history, value ranges and readiness estimates. * M14: log the Page Setup blur regression (#5650) Record the bounded unfocused margin display and accepted-value regression. Preserve every upstream progress entry and the previous validation history. * M14: regress RTL margin re-entry (#5650) Cover re-editing the visible portion of a wide blurred margin in the real Page Setup dialog under an asserted RTL fixture. Raw pointer events restore editing and accept a normal value; the existing thirteen tests remain unchanged. Fourteen Page Setup tests, package formatting, strict all-target UI Clippy and source-integrity checks passed. No production or dependency changes. --- ROADMAP.md | 6 + crates/ui-egui/src/dialogs/dispatch.rs | 20 +++- crates/ui-egui/src/dialogs/page_setup.rs | 5 +- crates/ui-egui/src/dialogs/tests.rs | 139 +++++++++++++++++++++++ docs/gaps.md | 6 + docs/ui-parity.md | 6 + 6 files changed, 176 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1d32c72648..55661f989b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,8 @@ > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > > **Headered CSV prefix update:** 2026-10-31 · Unquoted whitespace-only prefix lines are skipped before choosing recipient headers (#4249); quoted whitespace headers, headerless records, the shared CSV reader and recipient limits remain unchanged. + +> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. > **Legacy form update:** 2026-10-22 · trivial: numeric XML references decode in field names and labels; readiness estimates unchanged. @@ -464,6 +466,8 @@ 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-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | +| 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) | | 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-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 | @@ -812,6 +816,8 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | +| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 4278efa072..aa1268eb42 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -581,8 +581,20 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } /// A margin's number box, in the unit the interface measures in. -fn margin(value: &mut f32) -> egui::DragValue<'_> { - egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2) +fn margin(value: &mut f32) -> impl egui::Widget + '_ { + move |ui: &mut Ui| { + // DragValue's unfocused button extends to fit its value; keep it inside the field too. + let (_, rect) = ui.allocate_space(egui::vec2(80.0, ui.spacing().interact_size.y)); + let mut field = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(crate::rtl::row_layout(ui, egui::Align::Center))); + field.shrink_clip_rect(rect); + field.add( + egui::DragValue::new(value) + .speed(3.6 / crate::options::unit().pt_per_unit()) + .measure(crate::options::unit()) + .max_decimals(2) + .clip_text(true), + ) + } } fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { @@ -640,9 +652,9 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| { t.row(|r| { let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")))); - r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id)); + r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000).clip_text(true)).labelled_by(chars_label.id)); let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")))); - r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id)); + r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000).clip_text(true)).labelled_by(lines_label.id)); }); }); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/page_setup.rs b/crates/ui-egui/src/dialogs/page_setup.rs index 8d18598420..73ed71e818 100644 --- a/crates/ui-egui/src/dialogs/page_setup.rs +++ b/crates/ui-egui/src/dialogs/page_setup.rs @@ -112,12 +112,13 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: & t.row(|r| { let width_label = r.cell(|ui| ui.label(tl!(l.width))); let w = r.cell(|ui| { - ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)) + ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2).clip_text(true)) .labelled_by(width_label.id) }); let height_label = r.cell(|ui| ui.label(tl!(l.height))); let h = r.cell(|ui| { - ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id) + ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2).clip_text(true)) + .labelled_by(height_label.id) }); if (w.changed() || h.changed()) && paper.0 != paper.1 { *landscape = paper.0 > paper.1; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 184f9a3a93..7fe402ee75 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -985,6 +985,145 @@ fn opened_paper(app: &mut WordApp) -> ((f32, f32), Option<&'static str>) { ((paper.0 * ppu, paper.1 * ppu), paper_entry(paper)) } +/// #5650: a long paste scrolls inside Page Setup's number boxes instead of enlarging the +/// dialog, including after leaving the field and reopening the dialog. +#[test] +fn page_setup_number_fields_do_not_expand_after_paste() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + for label in ["Bottom:", "Top:", "Left:", "Right:", "Width:", "Height:", "Characters per line:", "Lines per page:"] { + let mut h = app_harness(app_with("Page")); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + if let Some(Dialog::PageSetup { grid, .. }) = h.state_mut().dialog.as_mut() { + grid.0 = 2; // Enable both document-grid number boxes. + } + h.run_steps(3); + let width = + |h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width(); + let before = width(&h); + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Paste("a".repeat(400))); + h.run_steps(3); + assert!(width(&h) <= before + 2.0, "{label}: pasted text grew the dialog from {before} to {}", width(&h)); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(width(&h) <= before + 2.0, "{label}: leaving the field kept a larger dialog"); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(3); + assert!(width(&h) <= before + 2.0, "{label}: reopening kept a larger dialog"); + } +} + +/// A finite value can outgrow DragValue's unfocused button even when its editor clips. +#[test] +fn page_setup_large_numeric_margins_do_not_expand_after_leaving_field() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + for label in ["Bottom:", "Top:", "Left:", "Right:"] { + let mut h = app_harness(app_with("Page")); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(3); + let width = + |h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width(); + let before = width(&h); + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Paste(format!("1{}", "0".repeat(36)))); + h.run_steps(3); + let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") }; + let value = match label { + "Bottom:" => *bottom, + "Top:" => *top, + "Left:" => *left, + _ => *right, + }; + assert!(value.is_finite() && value > 1e35, "{label}: the large finite paste was accepted: {value}"); + assert!(width(&h) <= before + 2.0, "{label}: editing grew the dialog from {before} to {}", width(&h)); + h.key_press(egui::Key::Tab); + h.run_steps(3); + let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") }; + let after = match label { + "Bottom:" => *bottom, + "Top:" => *top, + "Left:" => *left, + _ => *right, + }; + assert_eq!(after, value, "{label}: leaving the field keeps the accepted value"); + assert!(width(&h) <= before + 2.0, "{label}: leaving the large numeric field grew the dialog from {before} to {}", width(&h)); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + } +} + +/// A clipped wide number in a mirrored form can still be clicked where it is visible. +#[test] +fn page_setup_rtl_large_margin_can_be_edited_again_after_blur() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut app = app_with("Page"); + app.run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + let ar = crate::i18n::fixture_lang("ar", "\tMargins\tMargins\n"); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + move |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + // Draw the actual dialog in a fixture language even without embedded catalogs. + crate::i18n::set_current(ar); + crate::rtl::sync(&ctx); + assert!(crate::rtl::on() && ctx.global_style().right_to_left); + show(app, &ctx); + crate::i18n::set_current(crate::i18n::Lang::EN); + }, + app, + ); + h.run_steps(4); + let initial = h.get_by_role_and_label(Role::SpinButton, "Bottom:").rect(); + assert!(h.get_by_role_and_label(Role::SpinButton, "Top:").rect().center().x > initial.center().x, "the form is mirrored"); + let window = |h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap(); + let before = window(&h).width(); + h.get_by_role_and_label(Role::SpinButton, "Bottom:").click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Paste(format!("1{}", "0".repeat(36)))); + h.run_steps(3); + let value = match h.state().dialog.as_ref() { + Some(Dialog::PageSetup { bottom, .. }) => *bottom, + _ => panic!("no page setup dialog"), + }; + assert!(value.is_finite() && value > 1e35); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == value)); + let field = h.get_by_role_and_label(Role::SpinButton, "Bottom:"); + assert!(!field.is_focused()); + let wide = field.rect(); + let visible = wide.intersect(initial).intersect(window(&h)); + assert!(wide.width() > 80.0 && visible.is_positive() && !visible.contains(wide.center())); + assert!(window(&h).width() <= before + 2.0); + // The AccessKit bounds include clipped text: click the visible part, not their centre. + let pos = visible.center(); + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.get_by_role_and_label(Role::SpinButton, "Bottom:").is_focused(), "the visible field enters editing"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Paste("2.5".into())); + h.run_steps(3); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == 2.5), "the field accepts a normal value again"); + assert!(window(&h).width() <= before + 2.0); +} + /// #1433: Page Setup's Paper size shows the named size the section has, in either /// orientation, and Custom size (None) for anything else. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index d8201df496..15e233e2a9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,8 @@ # Where WordCraft falls short of Word > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); 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 (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; 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 (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); 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 + +> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. > **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-11-06 · **Change:** 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 (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); 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 > @@ -162,6 +164,8 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l 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. +Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. Accepted long margin values also stay inside the field after focus leaves; their accepted range, units and parsing are unchanged. + ## Revision history > 2026-11-03 · Section `w:chapStyle` values now preserve the complete unsigned-byte range, including 10; values above 255 are rejected. @@ -178,6 +182,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | +| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6a402292cd..65cc763e48 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); 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 +> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. + 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). How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. @@ -97,10 +99,14 @@ Review › Spelling & Grammar, Home › Editor and F7 open a right-side Proofing 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. +Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. Accepted long margin values also stay inside the field after focus leaves; their accepted range, units and parsing are unchanged. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | +| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | From 0131d5c3aa76e36431a57ebe8c7e8f67396300e5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 01:51:17 +0100 Subject: [PATCH 08/38] .doc import: resolve character formatting per run, not per character (#5478) (#5495) * docbin: a field's cached result keeps its formatting per run, not per character (#5477) The result was a Vec<(char, CharProps)>: 365 bytes per character, 1.44 GB at peak for a 4 MB file with one field. It is now a String plus a run list that gains an entry only when the formatting changes (25 MB peak for the same file). Output is unchanged. Co-Authored-By: Claude Sonnet 5.5 * docbin: show the objects when the field-result test fails Co-Authored-By: Claude Sonnet 5.5 * docbin: resolve character formatting per run, not per character (#5478) The text loop looked up the piece four or five times and the CHPX once (a backward scan over up to 101 FKP entries) for every character, and cloned the memoised 368-byte CharProps each time. Now the walk keeps the piece span and the CHPX run of the previous character and reuses them while the next character is inside, and hands the memoised properties to the paragraph builder by reference: they are cloned when a run starts and not compared while the resolved formatting is unchanged. Output is identical, also on corrupt pages (a run's FC range is bounded by the smallest later start). Co-Authored-By: Claude Sonnet 5.5 * docbin: tests for run ranges across bins, empty pieces and a whole read Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/docbin/src/fkp.rs | 65 ++++++++++++---- crates/docbin/src/lib.rs | 123 +++++++++++++++++++----------- crates/docbin/src/piece.rs | 42 +++++++++-- crates/docbin/src/tests.rs | 150 +++++++++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 61 deletions(-) diff --git a/crates/docbin/src/fkp.rs b/crates/docbin/src/fkp.rs index 8ed395ac7c..3de272da26 100644 --- a/crates/docbin/src/fkp.rs +++ b/crates/docbin/src/fkp.rs @@ -7,9 +7,26 @@ use crate::DocbinError; /// Largest bin table (entries) we accept. const MAX_BINS: usize = 100_000; +/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns. +#[derive(Clone, Copy)] +pub(crate) struct ChpxRun<'w> { + pub(crate) grpprl: &'w [u8], + lo: u32, + hi: u32, +} + +impl ChpxRun<'_> { + pub(crate) fn contains(&self, fc: u32) -> bool { + self.lo <= fc && fc < self.hi + } +} + /// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in. pub(crate) struct Bins { fcs: Vec, + /// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt + /// table that does not gets no cached ranges, [`ChpxRun`]). + sorted: bool, /// Page number (× 512 = byte offset in the WordDocument stream). pns: Vec, } @@ -20,7 +37,7 @@ impl Bins { let fc = fc as usize; let lcb = lcb as usize; if lcb < 4 || !(lcb - 4).is_multiple_of(8) { - return Ok(Bins { fcs: Vec::new(), pns: Vec::new() }); + return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() }); } let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?; let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?; @@ -38,36 +55,58 @@ impl Bins { // PnBte* keeps the page number in its low bits; the rest must be zero. pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF); } - Ok(Bins { fcs, pns }) + let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1)); + Ok(Bins { fcs, sorted, pns }) } - /// The page containing `fc`, as a slice of the WordDocument stream. - fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> { + /// The page containing `fc`, as a slice of the WordDocument stream, and the FC range + /// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table + /// is not ascending, so nothing is cached from it). + fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> { let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?; let pn = *self.pns.get(i)?; let at = pn.checked_mul(512)? as usize; - word.get(at..at.checked_add(512)?) + let page = word.get(at..at.checked_add(512)?)?; + if !self.sorted { + return Some((page, 1, 0)); + } + let lo = self.fcs.get(i).copied().unwrap_or(0); + let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX); + Some((page, lo, hi)) } /// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2, /// crun in the last byte). pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] { - let Some(page) = self.page(word, fc) else { return &[] }; - let Some(&crun) = page.get(511) else { return &[] }; + self.chpx_run(word, fc).grpprl + } + + /// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk + /// over consecutive characters looks it up once per run, not once per character. + pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> { + let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 }; + let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none }; + let Some(&crun) = page.get(511) else { return none }; let crun = crun.min(0x65) as usize; let rgfc = |i: usize| -> Option { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; // rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the // region between rgb's end and crun. let rgb_at = (crun + 1) * 4; + // The last run starting at or before `fc`. It stays the answer for every FC from its own + // start up to the smallest start after it (also in a page whose starts do not ascend). let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc)); - let Some(j) = j else { return &[] }; - let Some(&off) = page.get(rgb_at + j) else { return &[] }; + let first_after = j.map_or(0, |j| j + 1); + let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi)); + let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo)); + let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi }; + let Some(j) = j else { return run(&[]) }; + let Some(&off) = page.get(rgb_at + j) else { return run(&[]) }; if off == 0 { - return &[]; + return run(&[]); } let at = off as usize * 2; - let Some(&cb) = page.get(at) else { return &[] }; - page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]) + let Some(&cb) = page.get(at) else { return run(&[]) }; + run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])) } /// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc` @@ -75,7 +114,7 @@ impl Bins { /// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a /// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark. pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) { - let Some(page) = self.page(word, fc) else { return (0, &[]) }; + let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) }; let Some(&crun) = page.get(511) else { return (0, &[]) }; // BX is 13 bytes; cap crun at what fits before byte 511. let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13; diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index e97e2676d3..a2461d3fb2 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -502,29 +502,37 @@ struct ParaBuild { runs: Vec, props: wordcraft_doc::props::ParaProps, mark: CharProps, - /// The current run's properties, so runs only break when formatting changes. - cur: Option, + /// Which resolved formatting ([`FmtState::generation`]) the last character came from, while + /// the last run is still the one it extended: the same generation means equal properties. + run_gen: Option, /// Inline objects, each anchored at a U+FFFC pushed into `text`. objects: Vec, } impl ParaBuild { fn push(&mut self, c: char, props: CharProps) { + self.run_gen = None; let len = c.len_utf8(); - match &self.cur { - Some(p) if *p == props => { - if let Some(r) = self.runs.last_mut() { - r.len += len; - } - } - _ => { - self.runs.push(Run { len, props: props.clone() }); - self.cur = Some(props); - } + match self.runs.last_mut() { + Some(r) if r.props == props => r.len += len, + _ => self.runs.push(Run { len, props }), } self.text.push(c); } + /// [`ParaBuild::push`] for properties that are resolved once and shared by many characters: + /// they are cloned only when a run starts, and not even compared while `generation` stays + /// the same. + fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) { + let len = c.len_utf8(); + match self.runs.last_mut() { + Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len, + _ => self.runs.push(Run { len, props: props.clone() }), + } + self.run_gen = Some(generation); + self.text.push(c); + } + /// Append `c` to the current run, whose properties the caller knows it matches. fn extend(&mut self, c: char) { if let Some(r) = self.runs.last_mut() { @@ -552,7 +560,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic // Field assembly state: instruction characters are swallowed, the result is kept until // the end character, then emitted as one object (hyperlinks keep their result text). let mut field: Option = None; - let mut memo: Option> = None; + let mut fmt = FmtState::default(); while cp < cp_end { // Bookmark boundaries at this CP (main-document walks only; subdocument walks get // an empty table and `cp` never matches the main-document CPs). @@ -715,16 +723,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() }); } } - '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo), - '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo), - '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo), - '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo), - '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo), - '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo), + '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt), + '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt), + '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt), + '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt), + '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt), + '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt), // Object anchors, comment references and other special characters are dropped // (or handled by later passes). c if (c as u32) < 0x20 => {} - c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo), + c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt), } cp = cp.saturating_add(units); } @@ -854,34 +862,53 @@ fn num_of(ilfo: i32) -> Option { /// plus the piece's Prm, over the paragraph style's base character props. Inside a field, /// the character is recorded into the instruction or the cached result instead of (or as /// well as) the visible text. -fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option>) { - let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); - let grpprl = ctx.chpx_bins.chpx(ctx.word, fc); - let prm = ctx.pieces.prm_of(cp); - let style = pb.props.style.as_deref(); - let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) { - Some(m) => m.props.clone(), +fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) { + // The piece and the CHPX run of the previous character usually cover this one too. + let span = match st.span.filter(|s| s.contains(cp)) { + Some(s) => Some(s), None => { - let default; - let base = match style.and_then(|id| ctx.sheet.get(id)) { - Some(st) => &st.chr, - None => { - default = CharProps::default(); - &default - } - }; - let mut props = char_props(grpprl, ctx.fonts, base); - apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work); - *memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() }); - props + st.span = ctx.pieces.span(cp); + st.span } }; + let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0); + let run = match st.chpx.filter(|r| r.contains(fc)) { + Some(r) => r, + None => { + let r = ctx.chpx_bins.chpx_run(ctx.word, fc); + st.chpx = Some(r); + r + } + }; + let grpprl = run.grpprl; + let prm = span.map_or(0, |s| s.prm()); + let style = pb.props.style.as_deref(); + let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style); + if !hit { + let default; + let base = match style.and_then(|id| ctx.sheet.get(id)) { + Some(sty) => &sty.chr, + None => { + default = CharProps::default(); + &default + } + }; + let mut props = char_props(grpprl, ctx.fonts, base); + apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work); + st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props }); + st.generation += 1; + } + let Some(m) = st.memo.as_ref() else { return }; // A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with // sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1). + let symbol = if c == '(' { symbol_operand(grpprl) } else { None }; + if field.is_none() && symbol.is_none() { + pb.push_shared(c, &m.props, st.generation); + return; + } + let mut props = m.props.clone(); let mut c = c; - if c == '(' - && let Some((ftc, xchar)) = symbol_operand(grpprl) - { + if let Some((ftc, xchar)) = symbol { if let Some(font) = ctx.fonts.get(usize::from(ftc)) { props.font = Some(font.clone()); } @@ -900,6 +927,18 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f } } +/// What the walk carries from one character to the next while it resolves formatting: the piece +/// and the CHPX run the last character was in (so most characters need no search), and the last +/// formatting resolved. +#[derive(Default)] +struct FmtState<'a> { + span: Option>, + chpx: Option>, + memo: Option>, + /// Counts the memo's replacements, so equal values mean the same properties. + generation: u64, +} + /// The last character formatting resolved, reused while the CHPX, the piece's Prm and the /// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per /// formatting change, not once per character (#3692). diff --git a/crates/docbin/src/piece.rs b/crates/docbin/src/piece.rs index d52f968b7a..fda4e5470c 100644 --- a/crates/docbin/src/piece.rs +++ b/crates/docbin/src/piece.rs @@ -117,12 +117,6 @@ impl PieceTable { } } - /// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs - /// with the same value get the same [`PrmRef`]. - pub(crate) fn prm_of(&self, cp: u32) -> u16 { - self.piece_of(cp).map_or(0, |p| p.prm) - } - fn piece_of(&self, cp: u32) -> Option<&Pcd> { let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; if i >= self.pieces.len() { @@ -143,6 +137,17 @@ impl PieceTable { if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) } } + /// The piece holding `cp`, with the CP range it covers: for every CP in that range the + /// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone, + /// so a sequential walk searches the piece table once per piece. + pub(crate) fn span(&self, cp: u32) -> Option> { + let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; + let pcd = self.pieces.get(i)?; + let cp_lo = *self.cps.get(i)?; + let cp_hi = *self.cps.get(i + 1)?; + Some(PieceSpan { cp_lo, cp_hi, pcd }) + } + /// The CP just past the last piece: no text exists at or beyond it. pub(crate) fn cp_end(&self) -> u32 { self.cps.last().copied().unwrap_or(0) @@ -187,6 +192,31 @@ impl PieceTable { } } +/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]). +#[derive(Clone, Copy)] +pub(crate) struct PieceSpan<'a> { + cp_lo: u32, + cp_hi: u32, + pcd: &'a Pcd, +} + +impl PieceSpan<'_> { + pub(crate) fn contains(&self, cp: u32) -> bool { + self.cp_lo <= cp && cp < self.cp_hi + } + + /// [`PieceTable::fc_of_cp`] for a CP inside the span. + pub(crate) fn fc_at(&self, cp: u32) -> Option { + let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?; + if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) } + } + + /// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`]. + pub(crate) fn prm(&self) -> u16 { + self.pcd.prm + } +} + /// What a piece's `Prm` refers to. pub(crate) enum PrmRef<'a> { None, diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 1128eca38a..22ec2fc1e4 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2880,3 +2880,153 @@ fn field_results_keep_their_text_and_runs() { p.objects ); } + +/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream. +fn one_page_bins(page: &[u8], end: u32) -> (Vec, crate::fkp::Bins) { + let mut word = vec![0u8; 512]; + word.extend_from_slice(page); + let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat(); + let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins"); + (word, bins) +} + +/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk +/// reuses it for the following characters without asking again. +#[test] +fn a_chpx_run_covers_the_fcs_with_the_same_answer() { + let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))]; + let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); + for fc in (fc_of(0) - 4)..(fc_of(70)) { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}"); + for other in (fc_of(0) - 4)..(fc_of(70)) { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); + } + } + } + assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9))); +} + +/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and +/// a range that never claims an FC with a different answer. +#[test] +fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() { + let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))]; + let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000); + for fc in (fc_of(0) - 4)..(fc_of(70)) { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc)); + for other in (fc_of(0) - 4)..(fc_of(70)) { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}"); + } + } + } +} + +/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed +/// and UTF-16) and past the last. +#[test] +fn a_piece_span_matches_the_per_cp_lookups() { + let doc = text_doc( + &[ + Piece { compressed: true, text: "abc".into() }, + Piece { compressed: false, text: "d\u{e9}fgh".into() }, + Piece { compressed: true, text: "ij".into() }, + ], + &[], + 0, + ); + let mut comp = crate::open_container(&doc).expect("container"); + let table = crate::stream(&mut comp, "0Table").expect("table"); + let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces"); + for cp in 0..14 { + let span = pieces.span(cp); + assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}"); + for other in 0..14 { + if span.is_some_and(|s| s.contains(other)) { + assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other)); + } + } + } + assert!(pieces.span(10).is_none(), "past the last piece"); +} + +/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not +/// even compared while the formatting stays the same. +#[test] +fn shared_props_start_one_run_per_formatting() { + use wordcraft_doc::props::CharProps; + let bold = CharProps { bold: Some(true), ..Default::default() }; + let mut pb = super::ParaBuild::default(); + for _ in 0..100_000 { + pb.push_shared('a', &bold, 1); + } + pb.push_shared('b', &CharProps::default(), 2); + pb.push_shared('c', &bold, 3); + pb.push_shared('d', &bold, 4); + assert_eq!(pb.runs.iter().map(|r| r.len).collect::>(), [100_000, 1, 2], "equal props under a new generation join the run"); + pb.push('e', bold.clone()); + pb.push_shared('f', &bold, 4); + assert_eq!( + pb.runs.iter().map(|r| r.len).collect::>(), + [100_000, 1, 4], + "a plain push forgets the generation but still joins an equal run" + ); + assert_eq!(pb.text.len(), 100_005); +} + +/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a +/// run is reported as valid it gives the answer a fresh lookup gives. +#[test] +fn chpx_runs_are_sound_across_bins() { + let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20); + let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40); + let mut word = vec![0u8; 512]; + word.extend_from_slice(&page_a); + word.extend_from_slice(&page_b); + let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60)); + let plc = |fcs: &[u32], pns: &[u32]| { + [fcs.iter().flat_map(|f| f.to_le_bytes()).collect::>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat() + }; + let tables = [ + ("two bins", plc(&[0, a, end], &[1, 2])), + ("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])), + ("a missing page", plc(&[0, a, end], &[1, 9])), + ("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])), + ("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])), + ]; + for (what, t) in &tables { + let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins"); + let probes: Vec = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect(); + for &fc in &probes { + let run = bins.chpx_run(&word, fc); + assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}"); + for &other in &probes { + if run.contains(other) { + assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}"); + } + } + } + } +} + +/// The same formatting changes through a whole read: runs break exactly where the CHPX changes +/// (with a field and a symbol character in between), and nowhere else. +#[test] +fn formatting_runs_break_where_the_chpx_changes() { + let spec = RichSpec { + text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r", + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman", "Arial"], + chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))], + papx_entries: vec![(30, 0, Vec::new())], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let p = para0(&doc); + let shape: Vec<(usize, Option, Option)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect(); + // "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold + // (the run from the field to CP 20), "d(ee" italic. + assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}"); +} From 746577406c53d0e960c3bcac71e349f00c9bbe67 Mon Sep 17 00:00:00 2001 From: Aritz Velasco <118175629+iAmRarted@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:51:45 +0100 Subject: [PATCH 09/38] Links: Ctrl+K on a selection shows the changed Text to display (#5497) * Ctrl+K on a selection shows the changed Text to display With text selected, the Link dialog's Text to display was ignored: insert.link only linked the selection. A changed text now retypes the selection (keeping its formatting, tracked with Track Changes on) and links it. The same text, text with a line break, or a selection over several paragraphs or holding a picture or field keeps its text. Co-Authored-By: Claude Opus 5.5 * Links: keep selections a changed Text to display can't replace Review of the Ctrl+K retype: a selection holding a manual line break, page or column break, or sitting in a field's result is only linked, so neither both lines nor the field's result are lost. `select.text` clears the caret formatting as `select.word` does, so a second retyped link keeps its selection's bold instead of the first link's default props. Co-Authored-By: Claude Opus 5.5 * Selections: select.all, select.range and select.word drop pending formatting A link leaves plain formatting pending for what is typed after it. These commands kept it, so retyping the next selection took that instead of the selection's own formatting, as select.text and select.word's caret moves already avoid. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/caret.rs | 6 ++ crates/engine/src/cmd/insert/links.rs | 38 ++++++++++ crates/engine/src/tests/typing.rs | 104 ++++++++++++++++++++++++++ docs/typing-parity.md | 10 ++- 4 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index fa07b3e60a..23b83e7084 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult { } let st = s.sel.focus.story; s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) }; + s.pending = None; s.column = None; s.column_mode = false; sel_result(s) @@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult { let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?; let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?; s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) }; + s.pending = None; sel_result(s) } @@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult { if s.doc.para_at(&f).is_some() { let (a, b) = unit_range(s, &f, unit); s.sel = Selection { anchor: a, focus: b }; + s.pending = None; } sel_result(s) } @@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult { if count == n { s.sel = Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } }; + // Formatting picked for the old caret doesn't carry over (as `select.word`): what + // replaces the selection takes its formatting. + s.pending = None; return sel_result(s); } } diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs index b280d449bb..2a0535a651 100644 --- a/crates/engine/src/cmd/insert/links.rs +++ b/crates/engine/src/cmd/insert/links.rs @@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult { let end = s.doc.insert_text(&at, &text, &props)?; s.sel = Selection::caret(end); s.pending = Some(CharProps::default()); + } else if let Some(text) = retyped_text(s, v) { + // Text to display changed: the selection is retyped with it, then linked (as in Word). + crate::cmd::paste_text(s, &text)?; + let end = s.sel.focus.clone(); + let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() }; + s.doc.format_range(&start, &end, &|c| { + c.link = Some(url.clone()); + c.style = style.clone(); + })?; + s.sel = Selection::caret(end); + s.pending = Some(CharProps::default()); } else { let (a, b) = s.sel.ordered(); s.doc.format_range(&a, &b, &|c| { @@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// The `text` to retype a selection with before linking it: only when it differs from the selected +/// text and both fit in one line of one paragraph without pictures or fields, outside any field's +/// result. Otherwise (as Word shows `<>`) the selection keeps its text. +fn retyped_text(s: &Session, v: &Value) -> Option { + use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; + /// What can't stand in one line of text: breaks, and pictures or fields. + const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ]; + let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?; + if text.contains(NOT_ONE_LINE) { + return None; + } + let (a, b) = s.sel.ordered(); + if a.story != b.story || a.path != b.path { + return None; + } + let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?; + if shown.contains(NOT_ONE_LINE) || shown == text { + return None; + } + // A selection in a field's result (between its markers, which it doesn't hold) is retyped by + // the next update of the field, not here. + if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) { + return None; + } + Some(text.to_string()) +} + /// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters /// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref). pub fn bookmark_name_ok(name: &str) -> bool { diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 574ee1368c..01ab762111 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -1539,6 +1539,110 @@ fn typing_at_either_end_of_a_link_is_not_linked() { assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); } +#[test] +fn a_link_on_a_selection_shows_the_text_to_display() { + // Ctrl+K on selected text with a new Text to display: the selection is retyped with it and + // linked, keeping its formatting; typing after it stays outside the link (as in Word). + let mut s = typed("go see kiln now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + typ(&mut s, "!"); + assert_eq!(texts(&s), ["go see oven! now"]); + assert_eq!(links(&s), [("Oven".into(), false)]); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting"); + // The same text, or text that can't stand in one line, leaves the selection as it is. + run(&mut s, "select.text", json!({"text": "see"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"})); + run(&mut s, "select.text", json!({"text": "now"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"})); + assert_eq!(texts(&s), ["go see oven! now"]); +} + +#[test] +fn a_new_selection_drops_the_formatting_left_after_a_link() { + // A link leaves plain formatting for what is typed after it. A selection made next doesn't + // take it: retyping that selection keeps its own formatting. + let bold_run = |s: &Session, text: &str| { + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true)) + }; + let mut s = typed("kiln"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"})); + assert!(bold_run(&s, "Stove"), "select.all"); + run(&mut s, "select.word", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"})); + assert!(bold_run(&s, "Range"), "select.word"); + let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap(); + let focus = serde_json::to_value(s.sel.focus.clone()).unwrap(); + run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"})); + assert!(bold_run(&s, "Hob"), "select.range"); +} + +#[test] +fn a_link_over_two_paragraphs_keeps_their_text() { + // Word shows <> for such a selection: the text to display is ignored. + let mut s = typed("one\ntwo"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"})); + assert_eq!(texts(&s), ["one", "two"]); + assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]); +} + +#[test] +fn a_retyped_link_text_is_tracked() { + let mut s = typed("go see kiln now"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion"); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some())); +} + +#[test] +fn a_link_keeps_a_selection_it_cannot_retype() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let url = "https://example.org/"; + // A second retyped link keeps its own selection's formatting: the first one's left no + // formatting pending for it. + let mut s = typed("go see kiln and vat now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Oven"})); + run(&mut s, "select.text", json!({"text": "vat"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "Tub"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text); + // A selection holding a manual line break keeps both lines. + let mut s = typed("one"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "two"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "both"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo"); + // A selection in a field's result is only linked; the field's next update owns that text. + let mut doc = wordcraft_doc::Document::from_text("See "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + run(&mut s, "select.text", json!({"text": "Smith"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Jones"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)"); + assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1); +} + #[test] fn typing_then_selecting_then_typing_are_two_undo_steps() { // #419: a selection that moves the insertion point ends the typing group, like a caret move. diff --git a/docs/typing-parity.md b/docs/typing-parity.md index ed66c594a3..60ae7c4f9b 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (equation `\int` + Space waits for its limits, `_x^y` then Space builds them and typing goes into the operand, and any character that can't continue `\name` corrects it, observed in Word for Windows on #4938, #4917); previously minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: a tracked heading or border-line AutoFormat replaces another author's formatting change; 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 (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (Ctrl+K on a selection retypes it with a changed Text to display); previously trivial (equation `\int` + Space waits for its limits, `_x^y` then Space builds them and typing goes into the operand, and any character that can't continue `\name` corrects it, observed in Word for Windows on #4938, #4917); previously minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: a tracked heading or border-line AutoFormat replaces another author's formatting change; 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 (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -222,6 +222,13 @@ no-break space, a figure space or an ideographic space (`autocorrect_finds_the_w isn't linked; typing inside it is. Linking a whole paragraph doesn't link its mark, so the paragraphs Enter makes from it aren't links (#413; `a_link_covers_only_its_own_text`, `typing_at_either_end_of_a_link_is_not_linked`). +- Ctrl+K on selected text with a changed Text to display retypes the selection with that text, + keeping its formatting (tracked with Track Changes on), and links it. The same text, text with + a line break (typed or selected), a selection over several paragraphs, one holding a picture + or field, or one inside a field's result keeps its text and is only linked, as Word shows + `<>` there (WordCraft's rule for those; not compared live) + (`a_link_on_a_selection_shows_the_text_to_display`, `a_link_over_two_paragraphs_keeps_their_text`, + `a_retyped_link_text_is_tracked`, `a_link_keeps_a_selection_it_cannot_retype`). - A field covers only its own result: text typed or pasted with the caret just after a field (a Zotero citation at the end of a paragraph, the caret put there with a click) goes after the field, not into its result (#898; `typing_after_a_citation_goes_outside_its_field`). @@ -574,6 +581,7 @@ this correction; native Windows interaction and Microsoft Word remain untested. | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked | | 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not | | 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) | | 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) | From 4ce92e8afddf882f51465d5a67a5654afc60d27a Mon Sep 17 00:00:00 2001 From: Bra1nZ <95569966+Bra1nZ@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:56:04 +0300 Subject: [PATCH 10/38] backstage: the community link honors the community-button setting (#5909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Options' "Show the community button in the title bar" hid only the title-bar pill; the backstage menu still showed "Discord community" at its bottom — one setting, half honored. The menu now draws the link only when the same setting is on, next to About. Co-authored-by: Bra1nZ --- crates/ui-egui/src/backstage.rs | 13 ++++++++++++- crates/ui-egui/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index a1605c66a4..d3fd4a4ccc 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -55,6 +55,17 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left pos2(band_min_x + x, center_y) } +/// The bottom of the backstage menu: About, and the community link unless the user turned the +/// community button off — the title bar pill honors the same setting, so the menu hides the +/// link with it. +fn bottom_links(app: &WordApp) -> Vec<(&'static str, &'static str)> { + let mut links = vec![("About", "about")]; + if app.ui.show_discord { + links.push(("Discord community", "discord")); + } + links +} + /// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open /// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing. pub fn show(app: &mut WordApp, ui: &mut Ui) { @@ -155,7 +166,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - for (label, id) in [("About", "about"), ("Discord community", "discord")] { + for (label, id) in bottom_links(app) { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label))); if resp.hovered() { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index c8103b6e6b..32136193d2 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -590,6 +590,25 @@ fn title_and_account_buttons_work() { assert!(h.query_by_label("User name:").is_some()); } +/// The Options toggle hides the community pill in the title bar; the backstage menu's +/// Discord link hides with it — one setting, everywhere the link shows. +#[test] +fn the_community_button_off_hides_the_backstage_discord_link_too() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.run("ui.backstage", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(2); + assert!(h.query_by_label("Discord community").is_some(), "the community link shows by default"); + + let mut a = app(); + a.ui.show_discord = false; + a.run("ui.backstage", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(2); + assert!(h.query_by_label("Discord community").is_none(), "the backstage link honors the same setting"); +} + /// File › Options › Advanced › Editing: alignment guides are a UI command, on by default and /// kept between runs. #[test] From 871401509ce44241adcf43c6e791c2224f752816 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sun, 8 Nov 2026 07:07:56 +0600 Subject: [PATCH 11/38] Scrolling: the document's scroll bars show without the pointer on them (#5746) (#5828) The document used egui's floating scroll bars, invisible until the pointer reached the right edge. The canvas now draws solid bars in the 14 px strip it already left for them, with a grey thumb that stands out from its track in light and dark themes and darkens under the pointer. Read mode still has no bars. Co-authored-by: Martin Ball --- crates/ui-egui/src/canvas.rs | 23 +++++++++ crates/ui-egui/src/canvas/tests.rs | 80 ++++++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ac52931bfc..4a002b5a6b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -90,6 +90,24 @@ const TILE_HEIGHT_PX: usize = 1024; /// are rendered again when they come back. const TEXTURE_KEEP_SCREENS: f32 = 3.0; +/// The document's scroll bars (#5746): solid, so they show wherever the pointer is (egui's +/// floating ones are invisible until the pointer nears them), and with their margin exactly as +/// wide as the [`SCROLLBAR`] room the pages leave, so they neither cover nor move the pages. +fn canvas_scroll_style() -> egui::style::ScrollStyle { + let solid = egui::style::ScrollStyle::solid(); + egui::style::ScrollStyle { bar_width: SCROLLBAR - solid.bar_inner_margin - solid.bar_outer_margin, ..solid } +} + +/// The document's scroll bar thumb (#5746): egui paints a solid bar's track in +/// `extreme_bg_color` and its thumb in the widget fills, which our palette sets alike (`input`), so +/// the thumb would vanish into its track. Grey at rest, darker (lighter in the dark theme) under +/// the pointer and while dragged. Only the scroll area's own visuals: the pages' contents keep theirs. +fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) { + visuals.widgets.inactive.bg_fill = t.border_strong; + visuals.widgets.hovered.bg_fill = t.input_border; + visuals.widgets.active.bg_fill = t.text_dim; +} + pub struct CanvasState { textures: HashMap, /// Visible full-resolution tiles of pages too tall for one GPU texture. @@ -1390,9 +1408,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view; let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); + // The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back. + let inherited = std::sync::Arc::clone(ui_area.style()); + ui_area.spacing_mut().scroll = canvas_scroll_style(); + canvas_scroll_thumb(ui_area.visuals_mut(), &t); let mut ime_geometry: Option<(Rect, Rect)> = None; let mut ime_interrupt = false; let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| { + ui.set_style(inherited); origin = ui.min_rect().min - viewport.min.to_vec2(); let content = Rect::from_min_size(ui.min_rect().min, geo.size); let resp = canvas_response(ui, content, scroll_target); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02fefa45ec..7eaeeb863b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -755,6 +755,86 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() { } } +/// #5746: the document's scroll bars are drawn wherever the pointer is: before it has moved and +/// with it over the page, not only at the window's edge, with a thumb that stands out from its +/// track in the light and the dark theme. The vertical one fills the room the pages leave for it, +/// so it covers none of them; zoomed in, the horizontal one shows the same way. +#[test] +fn the_document_scroll_bars_show_with_the_pointer_away() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let bar = |h: &H, vertical: bool| { + h.query_all_by_role(Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| (r.height() > r.width()) == vertical) + .max_by(|a, b| a.area().total_cmp(&b.area())) + }; + // The opaque fills across the bar's whole width, longest first: its track, then its thumb. + let drawn = |h: &H, bar: Rect, vertical: bool| { + let across = |r: &Rect| if vertical { r.width() } else { r.height() }; + let along = |r: &Rect| if vertical { r.height() } else { r.width() }; + let mut fills: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill.a() > 0 && bar.expand(0.5).contains_rect(r.rect) && across(&r.rect) >= across(&bar) - 0.5 => { + Some((along(&r.rect), r.fill)) + } + _ => None, + }) + .collect(); + fills.sort_by(|a, b| b.0.total_cmp(&a.0)); + fills + }; + // The thumb shows on its track: a clear step in grey, not the same fill. + let thumb_shows = |fills: &[(f32, egui::Color32)], what: &str| { + let [(track_len, track), (thumb_len, thumb), ..] = fills else { panic!("{what}: a track and a thumb are drawn: {fills:?}") }; + assert!(thumb_len < track_len, "{what}: the thumb is shorter than its track: {fills:?}"); + let step = (0..3).map(|i| (i32::from(track[i]) - i32::from(thumb[i])).abs()).max().unwrap_or(0); + assert!(step >= 40, "{what}: the thumb {thumb:?} stands out from its track {track:?}"); + }; + for theme in ["light", "dark"] { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for _ in 0..3 { + a.run("text.insert", json!({"text": "Page text"})).unwrap(); + a.run("layout.break", json!({"kind": "page"})).unwrap(); + } + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("ui.theme", json!({"value": theme})).unwrap(); + let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.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_eq!(Tokens::get(&h.ctx).dark, theme == "dark", "the {theme} theme is on"); + let v = bar(&h, true).expect("the document's vertical scroll bar"); + thumb_shows(&drawn(&h, v, true), &format!("{theme}, before the pointer has moved")); + let viewport = h.state().canvas.viewport.expect("the canvas is shown"); + assert!((v.right() - viewport.right() - SCROLLBAR).abs() < 0.5, "the bar fills the room left for it: {v:?} beside {viewport:?}"); + assert!(h.state().canvas.page_rects.iter().all(|p| p.right() <= v.left()), "the bar covers no page"); + let page = h.state().canvas.page_rects.first().copied().expect("a page on screen"); + h.event(egui::Event::PointerMoved(page.center())); + for _ in 0..30 { + h.step(); + } + let v = bar(&h, true).expect("the document's vertical scroll bar"); + thumb_shows(&drawn(&h, v, true), &format!("{theme}, the pointer over the page")); + h.state_mut().run("view.zoom", json!({"value": 400.0})).unwrap(); + for _ in 0..30 { + h.step(); + } + let across = bar(&h, false).expect("zoomed in, a horizontal scroll bar"); + thumb_shows(&drawn(&h, across, false), &format!("{theme}, the horizontal bar")); + } +} + /// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that /// is the page at the top of the window; while the caret is on screen, its page. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 65cc763e48..b8e71abcc6 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-11-04 · **Change:** trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); 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-11-06 · **Change:** trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); 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 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. @@ -55,6 +55,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | +| Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None and kept between Read Mode entries, documents and runs (`view.readPageColor`); ink, object drag previews and table drag arrows drawn over the pages follow it; Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 | @@ -106,6 +107,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | +| 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | From 7855a2d814ab8a49f6b1ea259fc5de61168ada8c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:08:54 -0600 Subject: [PATCH 12/38] fix(fields): quoted-argument backslash escapes; body PAGE in a cardinal-text section (#4579) * fix(fields): quoted-argument backslash escapes and body PAGE in a cardinal-text section A backslash inside a quoted field argument makes the next character literal, so TITLE "x \* Upper" shows x * Upper as Word does. A body PAGE field now uses the section's page number format (cardinal text shows words), as headers and footers already did. * Fields: PAGE in a body table follows the section number format; parity docs * fmt * docs: clarify quoted field escape rule * Fix page fields in fitted text boxes * Move fitted cache test after layout helpers --------- Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/doc/src/link.rs | 1 + crates/docx/tests/it/toc_ranges.rs | 1 + crates/engine/src/cmd/references.rs | 7 +- crates/engine/src/cmd/references/tests.rs | 15 +++- crates/layout/src/ctx.rs | 90 ++++++++++++++++++++++- crates/layout/src/floats.rs | 2 + crates/layout/src/headers.rs | 1 + crates/layout/src/page.rs | 2 + crates/layout/src/pass.rs | 24 +++++- crates/layout/src/tests/pages.rs | 90 +++++++++++++++++++++++ docs/file-format-parity.md | 6 ++ docs/gaps.md | 2 + docs/layout-parity.md | 3 + 14 files changed, 235 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 55661f989b..ff35056105 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -799,6 +799,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target Footer page-table height and body wrapping are corrected. Other pagination gaps remain open. +Quoted field escapes are interpreted like Word, and body `PAGE` fields use their active section number format in paragraphs and tables. Synthetic regressions cover fields and section layout. + Section page-numbering settings preserve `w:chapStyle` values across the full unsigned-byte range. MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate. diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 7ceeabb37b..5ef9e0e752 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -125,6 +125,7 @@ mod tests { ("HYPERLINK\t\"http://example.com\"", Some("http://example.com")), (r#"HYPERLINK"http://example.com""#, Some("http://example.com")), (r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")), + (r#"HYPERLINK "x \* Upper""#, Some("x * Upper")), (r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")), (r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)), (r#"HYPERLINK " http://example.com ""#, Some("http://example.com")), diff --git a/crates/docx/tests/it/toc_ranges.rs b/crates/docx/tests/it/toc_ranges.rs index 15d8fa3f02..d384c430a5 100644 --- a/crates/docx/tests/it/toc_ranges.rs +++ b/crates/docx/tests/it/toc_ranges.rs @@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() { for (para, link) in [ (complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")), (complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")), + (complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")), (complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")), (simple(r#"HYPERLINK \l "Sec" \m"#), Some("#Sec")), (simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")), diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 0cfeda45a4..5fb6195e8e 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -103,8 +103,9 @@ pub fn specs() -> Vec { /// A field instruction split into its name, positional arguments and switches (ECMA-376 /// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays -/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch -/// takes the word after it as its argument unless that word is another switch. +/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word: +/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its +/// argument unless that word is another switch. #[derive(Clone, Debug, Default, PartialEq)] pub struct FieldCode { pub name: String, @@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> { while let Some(c) = chars.next() { match c { '"' => break, - '\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()), + '\\' if chars.peek().is_some() => w.extend(chars.next()), c => w.push(c), } } diff --git a/crates/engine/src/cmd/references/tests.rs b/crates/engine/src/cmd/references/tests.rs index d28b0a0c8e..0f5520911c 100644 --- a/crates/engine/src/cmd/references/tests.rs +++ b/crates/engine/src/cmd/references/tests.rs @@ -1,3 +1,4 @@ +use super::properties::{new_property_value, text_result}; use super::*; fn fields(s: &Session) -> Vec<(String, String)> { @@ -446,7 +447,7 @@ fn update_fields_apply_the_capitalization_switches() { "seventh", "Seventh", "BUDGET", - "new \\* Upper" + "new * Upper" ] ); } @@ -548,6 +549,18 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() { assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}"); } +/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an +/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`). +#[test] +fn quoted_field_argument_backslash_escapes() { + for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)] + { + let value = new_property_value(instr).unwrap(); + assert_eq!(value, shown, "{instr}"); + assert_eq!(text_result(instr, value), shown, "{instr}"); + } + assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB"); +} /// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with /// their fresh results, as Word fills them in when it prints or exports even with "Update fields /// before printing" off; NUMWORDS and a locked field keep their stored results there, and the diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index 04e2abec32..6be9ebaeeb 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -59,9 +59,9 @@ pub(crate) struct Ctx<'a> { pub(crate) pin: Option, pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec>, pub(crate) header_tables: HashMap<(usize, u32, bool), Vec>, - /// Heights of text boxes that resize to fit their text, by (story, width), worked out once - /// per layout ([`textbox::fitted_height`]). - pub(crate) fitted: HashMap<(u32, u32), Option>, + /// Heights of text boxes that resize to fit their text, by story, width and page fields, + /// worked out once per matching layout context ([`textbox::fitted_height`]). + pub(crate) fitted: HashMap<(u32, u32, (u32, u32, u32, u32, u64, wordcraft_doc::section::NumFormat)), Option>, /// Each body float's latest placement: on which page and with what wrap area, when it is /// above its anchor paragraph with text before it ([`EarlyFloats`]). pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>, @@ -344,7 +344,7 @@ impl Ctx<'_> { /// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout). fn fitted_height(&mut self, id: u32, w: f32) -> Option { - let key = (id, w.to_bits()); + let key = (id, w.to_bits(), self.fields.page_key()); if let Some(h) = self.fitted.get(&key) { return *h; } @@ -455,3 +455,85 @@ pub(crate) fn with_pct_base(ctx: &mut Ctx, base: Option, f: impl FnOnce( ctx.pct_base = before; out } + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::para::{Float, ShapeKind, Wrap}; + use wordcraft_doc::{PartKind, TextBody, para_block}; + + #[test] + fn fitted_text_box_cache_separates_page_field_contexts() { + let mut doc = Document::from_text("Parent"); + let mut story_para = Paragraph::new(); + story_para + .insert_object( + 0, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, + &Default::default(), + ) + .unwrap(); + let id = doc.add_part(PartKind::TextBox, vec![para_block(story_para)]); + if let Some(part) = doc.parts.get_mut(&id) { + part.body = TextBody { auto_fit: true, ..Default::default() }; + } + let mut p = Paragraph::new(); + p.insert_object( + 0, + InlineObject::Shape { + kind: ShapeKind::TextBox, + w: 100.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Float { wrap: Wrap::Inline, ..Default::default() }, + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }, + &Default::default(), + ) + .unwrap(); + let opts = LayoutOptions::default(); + let mut cache = LayoutCache::new(); + let mut ctx = Ctx { + doc: &doc, + cache: &mut cache, + opts: &opts, + counters: Default::default(), + fields: FieldCtx::default(), + notes_hash: 0, + numbers: Default::default(), + eq_count: 0, + boxes: Default::default(), + chains: Default::default(), + grid: Default::default(), + hide_deleted: opts.hides_deleted(StoryRef::Body), + uses_totals: false, + track_totals: false, + pct_base: None, + quiet_marks: Vec::new(), + stacked_marks: Vec::new(), + mirror: false, + table_keys: Vec::new(), + pin: None, + footer_wraps: Default::default(), + header_tables: Default::default(), + fitted: Default::default(), + floats_above: Default::default(), + hf: Arc::new(HfLinks::new(doc.sections())), + alphas: Default::default(), + header_repeats: 0, + }; + + ctx.fitted_boxes(&p); + assert_eq!(ctx.fitted.len(), 1); + ctx.fields.page_format = wordcraft_doc::section::NumFormat::CardinalText; + ctx.fitted_boxes(&p); + assert_eq!(ctx.fitted.len(), 2, "the same box with a different PAGE format needs its own height"); + ctx.fitted_boxes(&p); + assert_eq!(ctx.fitted.len(), 2, "matching field context reuses the fitted height"); + } +} diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 44a6dd57da..5135278e14 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -463,6 +463,8 @@ fn anchor_floats_but( late: &[usize], ) -> (HashMap, Arc, Vec) { let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor; + ctx.fields.page = pb.number; + ctx.fields.page_format = pb.page_format; let (col_x, width) = (pb.col_x(), pb.col_w()); let mut rects = HashMap::new(); if !pb.web && place { diff --git a/crates/layout/src/headers.rs b/crates/layout/src/headers.rs index d3af09bc28..bf7bce7a83 100644 --- a/crates/layout/src/headers.rs +++ b/crates/layout/src/headers.rs @@ -204,6 +204,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { let fields = ctx.fields.clone(); let grid = std::mem::take(&mut ctx.grid); let track = std::mem::replace(&mut ctx.track_totals, false); + ctx.fields.page_format = sect.page_num_format; ctx.fields.page = page.number; let mut areas = again(ctx, 0, |ctx| { with_pct_base(ctx, base, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame)))).table_areas diff --git a/crates/layout/src/page.rs b/crates/layout/src/page.rs index b9f5c9d421..14c611f2da 100644 --- a/crates/layout/src/page.rs +++ b/crates/layout/src/page.rs @@ -9,6 +9,8 @@ use super::{Block, Border, FloatAlign, Page, ParaLayout, Rect, SectionProps, Sto pub(crate) struct PageBuilder<'a> { pub(crate) pages: Vec, pub(crate) sect: &'a SectionProps, + /// The current document section's numbering format, even when reflow uses first-section page geometry. + pub(crate) page_format: wordcraft_doc::section::NumFormat, pub(crate) sect_idx: usize, pub(crate) col: usize, pub(crate) cols: Vec<(f32, f32)>, diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index 92198ccb1d..2f0513019a 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -43,6 +43,7 @@ struct Tops { struct Sec<'p> { si: usize, sect: &'p SectionProps, + page_format: wordcraft_doc::section::NumFormat, start: SectionStart, tops: Tops, } @@ -158,10 +159,17 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps { s } -fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> { +fn new_page_builder<'p>( + sect: &'p SectionProps, + page_format: wordcraft_doc::section::NumFormat, + early: &'p EarlyFloats, + web: bool, + settle: bool, +) -> PageBuilder<'p> { PageBuilder { pages: Vec::new(), sect, + page_format, sect_idx: 0, col: 0, cols: Vec::new(), @@ -221,6 +229,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool .iter() .map(|(_, s)| { (!web && s.text_direction.is_turned()).then(|| { + ctx.fields.page_format = s.page_num_format; let top = body_top_for(ctx, s, s.headers.default); vertical::unturned_sect(s, top) }) @@ -232,6 +241,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool /// previous section's, and take its room. `fixed`: the margins are the room (continuous views and /// turned sections). fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fixed: bool) -> Tops { + ctx.fields.page_format = sect.page_num_format; let (heads, feet) = (hf.heads.set(si), hf.feet.set(si)); let hb = |i: Option| hf.defining_width(false, si, i); let fb = |i: Option| hf.defining_width(true, si, i); @@ -246,6 +256,8 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) { let (doc, sect, si) = (ctx.doc, sec.sect, sec.si); pb.sect = sect; + pb.page_format = sec.page_format; + ctx.fields.page_format = sec.page_format; pb.sect_idx = si; pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText; if !web && !doc.footnote_separators.is_empty() { @@ -263,6 +275,8 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p fn end_previous_section<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<'p>, si: usize, block: usize) { let Some((_, prev)) = si.checked_sub(1).and_then(|k| hf.sections.get(k)) else { return }; pb.sect = prev; + pb.page_format = prev.page_num_format; + ctx.fields.page_format = prev.page_num_format; pb.sect_idx = si - 1; let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1)); let top = body_top_for(ctx, prev, ph.default); @@ -281,6 +295,8 @@ fn start_section_page<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks< if filler && !mode.web { end_previous_section(ctx, pb, hf, sec.si, block); pb.sect = sec.sect; + pb.page_format = sec.page_format; + ctx.fields.page_format = sec.page_format; pb.sect_idx = sec.si; } if restart.is_some() { @@ -492,7 +508,7 @@ pub(crate) fn layout_pass( let first_sect = sections.first().map_or(&default_sect, |(_, s)| *s); let web_sect = mode.reflow.then(|| reflow_sect(first_sect, opts)); let sect_ref = web_sect.as_ref().unwrap_or(first_sect); - let mut pb = new_page_builder(sect_ref, early, mode.web, !may_rewrap && !mode.reflow); + let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, early, mode.web, !may_rewrap && !mode.reflow); let mut endnotes = endnote_queue(&ctx); // Text boxes laid out in balancing attempts that were thrown away. let mut boxes_discarded = 0usize; @@ -500,15 +516,17 @@ pub(crate) fn layout_pass( let turned = turned_sections(&mut ctx, sections, mode.web); let mut prev_turn = TextDirection::Horizontal; for (si, (end, sect)) in sections.iter().enumerate() { + let page_format = sect.page_num_format; let turn = if mode.reflow { TextDirection::Horizontal } else { sect.text_direction }; let sect: &SectionProps = match turned.get(si) { Some(Some(t)) => t, _ if mode.reflow => sect_ref, _ => sect, }; + ctx.fields.page_format = page_format; let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned()); let start = if si == 0 { SectionStart::NextPage } else { sect.start }; - let sec = Sec { si, sect, start, tops }; + let sec = Sec { si, sect, page_format, start, tops }; if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) { // The page the section before ends on is finished with its own margins and footnote // separators, before this section's replace them: the footnotes pending on it diff --git a/crates/layout/src/tests/pages.rs b/crates/layout/src/tests/pages.rs index 9c50997ac3..aa87d7eb31 100644 --- a/crates/layout/src/tests/pages.rs +++ b/crates/layout/src/tests/pages.rs @@ -612,3 +612,93 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() { assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin"); } } + +/// A body PAGE field shows the page number in the section's number format, as Word does (a +/// cardinal-text section shows "one"), and `\*` overrides it. +#[test] +fn body_page_field_uses_the_section_number_format() { + let mut d = Document::from_text("Page \nPage "); + d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText; + for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + let off = p.len(); + let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }; + p.insert_object(off, field, &Default::default()).unwrap(); + } + let l = lay(&d); + let shown: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::()) } else { None }) + .collect(); + assert_eq!(shown, ["one", "ONE"], "{shown:?}"); +} + +#[test] +fn reflow_page_field_uses_the_current_section_number_format() { + let mut d = Document::from_text("First section\nSecond section"); + let first = d.last_section.clone(); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first)); + d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText; + let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + let off = p.len(); + p.insert_object( + off, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, + &Default::default(), + ) + .unwrap(); + + for view in [ViewMode::Web, ViewMode::Draft, ViewMode::Read, ViewMode::Outline] { + let mut cache = LayoutCache::new(); + let layout = layout(&d, &mut cache, &LayoutOptions { view, ..Default::default() }); + let shown: Vec<_> = layout + .pages + .iter() + .flat_map(|page| { + page.items.iter().filter_map(move |item| { + if let Placed::Lines { para, path, .. } = item { + (path == &Path::top(1)).then(|| (page.number, para.shown.iter().map(|x| x.1.as_str()).collect::())) + } else { + None + } + }) + }) + .collect(); + assert!(!shown.is_empty(), "{view:?}: no second-section text"); + for (number, text) in shown { + let expected = wordcraft_doc::section::NumFormat::CardinalText.format(number).to_lowercase(); + assert!(text.ends_with(&expected), "{view:?}, page {number}: {text:?}, expected {expected:?}"); + } + } +} + +/// A PAGE field in a table that opens the body uses the section's number format too: the table +/// is placed without the paragraph path that sets it. +#[test] +fn table_cell_page_field_uses_the_section_number_format() { + use wordcraft_doc::Table; + let mut d = Document::from_text("after"); + d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText; + let mut t = Table::new(1, 1, 200.0); + let mut p = wordcraft_doc::Paragraph::with_text("", Default::default()); + let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }; + p.insert_object(0, field, &Default::default()).unwrap(); + if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) { + c.blocks = vec![para_block(p)]; + } + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + let l = lay(&d); + let shown: String = l.pages[0] + .items + .iter() + .filter_map(|it| { + if let Placed::Lines { para, path, .. } = it { + (path.0.len() > 1).then(|| para.shown.iter().map(|x| x.1.clone()).collect::()) + } else { + None + } + }) + .collect(); + assert_eq!(shown, "one"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c803cf27bd..ebbfa88077 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -307,6 +307,10 @@ position with that tag. Tag case, unknown-source caches, source records, field i flags, style sorting and native/DOCX save behavior remain unchanged. Multi-source discovery, CSL and library import are separate work. +## Field arguments + +For field results, the engine parser treats a backslash in quoted arguments as escaping the next character, as Word does. The DOCX HYPERLINK target reader applies the same rule; escaped asterisks remain argument text instead of starting a format switch. Engine parser and DOCX import tests cover these cases. + ## Revision history > 2026-11-03 · Section `w:chapStyle` values preserve the full unsigned-byte range, including 10, while values above 255 are rejected. @@ -324,6 +328,8 @@ CSL and library import are separate work. | 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. | | 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. | | 2026-11-05 | trivial | ODT: a frame offering one picture in several formats (LibreOffice's `.svm` metafile, then a PNG) shows the first WordCraft can read; it took the first whatever it was, and the picture was missing on screen and gone once the paragraph was rewritten An object's replacement picture coming before it is kept too. A readable picture after an unreadable replacement is shown, wherever the object comes, and a save from the model keeps both. | +| 2026-11-04 | trivial | Engine field parsing and DOCX HYPERLINK targets consume quoted backslash escapes consistently | +| 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | diff --git a/docs/gaps.md b/docs/gaps.md index 15e233e2a9..5730b9254e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -184,6 +184,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text |---|---|---| | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | +| 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | +| 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2358783040..22f362923f 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -271,6 +271,7 @@ new mixed-style emoji and mixed bundled-font combining-mark controls cover LTR/b hit stops, full selection, and narrow/overwide line wrapping. Independent review and the full layout suite are pending; this is not a new Word/native-IME acceptance claim. +Body `PAGE` fields in paragraphs and tables use the active section's number format; section headers and footers are measured with that format before body placement. Regression coverage includes Web, Draft, Read and Outline views. MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate. The front-of-text opt-out is retained from the earlier implementation; this patch does not establish native Word equivalence between front-of-text and behind-text pictures. Synthetic placement tests cover the two paths, while a new native comparison of those wrap modes remains open. Turned-cell layouts disable horizontal cell confinement because their frame x axis becomes page y. @@ -295,6 +296,8 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | +| 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From e53cae3482d4e080a15dc2320eb4929ea7722db2 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:10:58 +0100 Subject: [PATCH 13/38] RTF: honor Unicode fallback counts above eight (#5607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * RTF: honor the declared Unicode fallback count * RTF: record declared Unicode fallback counts * M12: count unknown RTF symbols in Unicode fallback Address the unrecognized-control-symbol review finding in parse.rs. Each symbol consumes one Unicode fallback unit; outside fallback it remains ignored. Four additional actual-import cases cover uc0, uc1, uc2 and uc18. The uc2 regression fails before the fix (≤ rest versus ≤b rest). All 664 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact-source isolated closure. Binary fallback remains separate in #4806. * M12: count structural RTF words in Unicode fallback Treat par, pard, cell and row like other control words while consuming declared Unicode fallback. Add actual import cases for those fallback units and a uc0 paragraph control. Scoped rustfmt passes; full validation is pending hosted CI. * M12: describe RTF fallback units precisely Address the review wording finding: the Unicode fallback count measures units, including control words and escaped symbols, rather than fixed-length bytes. Preserve every roadmap history and progress record. * M12: count all RTF fallback control symbols Consume non-hex control symbols before dispatch while Unicode fallback remains, covering escaped LF/CR and star without applying their side effects. Keep valid hex escapes on their four-byte path, and include revision-author text in the existing control-word guard. Add six actual body-import cases and two actual revision-author cases, including uc0 controls. Both regressions fail before the patch in the exact current closure; all 665 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Binary and invalid-hex handling remain separate. Full hosted gate will be checked on the draft PR. * M12: preserve the roadmap final newline Restore the trailing LF omitted by copying the Raw view. The roadmap now differs only by the requested fallback-unit wording; production code and tests are unchanged. * M12: skip RTF info effects during Unicode fallback Move the existing info_depth assignment after the existing fallback-control-word guard. A skipped info word inside a Unicode title fallback must not overwrite metadata depth and drop the following author. Preserve the binary and escaped-hex paths, other destination handling and uc0 effects. The supplied metadata import regresses before the move; all 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Regression coverage follows in this draft PR. * M12: cover RTF fallback metadata depth Add four actual RTF import cases asserting title, creator and body text: skipped info controls at uc9 and uc1 retain the following author; uc0 title and a real info group after body Unicode still work. The supplied uc9 case fails before the parser reorder with an empty creator instead of Alice. All 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact c153 source closure plus this two-file follow-up. Full hosted CI will be checked on the draft PR. --------- Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/formats/src/rtf/parse.rs | 15 ++++++--- crates/formats/src/rtf/tests.rs | 56 +++++++++++++++++++++++++++++++++ crates/formats/src/rtf/word.rs | 2 +- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ff35056105..d7852dd23a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged. - 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged. +- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -829,6 +830,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged | | 2026-10-30 | trivial | Progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, and any character that cannot continue a math name corrects it, as observed in Word for Windows on #4938; Enter, arrow keys and nested operators are WordCraft's own choices (#4917); readiness unchanged | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. | +| 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged | | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | | 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | diff --git a/crates/formats/src/rtf/parse.rs b/crates/formats/src/rtf/parse.rs index d92545bd49..c8249c75ec 100644 --- a/crates/formats/src/rtf/parse.rs +++ b/crates/formats/src/rtf/parse.rs @@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result { i = end; continue; } - if word == "info" { - r.info_depth = Some(r.depth()); - } if r.skip_chars > 0 - && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl) - && !matches!(word, "par" | "pard" | "cell" | "row") + && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl) { r.skip_chars -= 1; first_in_group = false; continue; } + if word == "info" { + r.info_depth = Some(r.depth()); + } r.word(word, param, starred, first_in_group); first_in_group = false; starred = false; continue; } + if r.skip_chars > 0 && n != b'\'' { + r.skip_chars -= 1; + i += 2; + first_in_group = false; + continue; + } match n { b'\'' => { let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index 2add84f341..d55ba62252 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -37,6 +37,62 @@ fn basic_reader() { assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b"))); } +#[test] +fn unicode_fallback_uses_the_declared_count() { + for (rtf, expected) in [ + (br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"), + (br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"), + (br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"), + (br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"), + (br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"), + (br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"), + (br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"), + (b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"), + (b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"), + (b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"), + (b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"), + (br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc0\u233X}", "éX"), + (br"{\rtf1\uc1\u233?X}", "éX"), + (br"{\rtf1{\uc2147483647\u233?}X}", "éX"), + (br"{\rtf1\uc-1\u233X}", "éX"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}"); + } +} + +#[test] +fn unicode_fallback_info_words_preserve_following_metadata() { + for (rtf, title, body) in [ + (br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"), + (br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"), + (br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"), + (br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.core.title, title, "{rtf:?}"); + assert_eq!(doc.core.creator, "Alice", "{rtf:?}"); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}"); + } +} + +#[test] +fn unicode_fallback_in_revision_authors_counts_control_words() { + for (rtf, expected) in [ + (br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"), + (br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}"); + } +} + #[test] fn paragraph_borders() { // A heading with a grey rule under it, as hh.ru résumés write their section titles. diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index b3cb909f9b..87a033caab 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -89,7 +89,7 @@ impl Reader { } self.skip_chars = self.st.uc; } - "uc" => self.st.uc = pv.clamp(0, 8) as usize, + "uc" => self.st.uc = pv.max(0) as usize, "plain" => { self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() }; self.st.rev = RevSt::default(); From ea8c45a4ca3820ae0d76808eaeb8502d5ef78c04 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:52:08 +0100 Subject: [PATCH 14/38] CLI: preserve non-Unicode input paths after options (#5611) * M14: keep native CLI input names after options * docs(M14): clarify native CLI path option exceptions Qualify the roadmap summary and history: text option values need Unicode, while run --file and run --script retain native paths. Addresses review4485129728. Documentation only; no CLI behavior changes. * docs(M14): qualify Unicode values in format parity notes Exclude the existing run --file and run --script native path operands from the text option Unicode requirement. Addresses review4485129728; documentation only. * docs(M14): qualify Unicode values in CLI gap notes Document the existing native path exceptions for run --file and run --script. Completes the three-file wording correction requested in review4485129728, without changing readiness estimates or CLI behavior. --------- Co-authored-by: Martin Ball --- ROADMAP.md | 4 ++++ apps/wordcraft-cli/src/main.rs | 21 +++++++++++++++----- apps/wordcraft-cli/src/tests.rs | 5 +++++ apps/wordcraft-cli/tests/it/non_utf8_args.rs | 9 +++++++++ docs/file-format-parity.md | 3 +++ docs/gaps.md | 3 +++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d7852dd23a..b162eef38a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # WordCraft roadmap +> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. + > **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. **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away @@ -323,6 +325,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. | | 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture | |---|---| | 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland | @@ -829,6 +832,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged | | 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged | | 2026-10-30 | trivial | Progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, and any character that cannot continue a math name corrects it, as observed in Word for Windows on #4938; Enter, arrow keys and nested operators are WordCraft's own choices (#4917); readiness unchanged | +| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. | | 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged | | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 669da7c377..3d4235786d 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -98,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result { Ok(s) } -/// The commands that open a file, and which of their arguments not starting with `--` it is (as -/// `pos` counts). `run` opens the value of `--file`. +/// The commands that open a file, and which positional argument it is, excluding option values. +/// `run` opens the value of `--file`. const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)]; /// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode, @@ -108,8 +108,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), fn text_args>(raw: &[S]) -> Result<(Vec, Option), String> { let args: Vec = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect(); let cmd = args.first().map(String::as_str).unwrap_or(""); + let specs = match cmd { + "convert" => CONVERT_OPTIONS, + "render" => RENDER_OPTIONS, + "zotero" => ZOTERO_OPTIONS, + _ => PASSWORD_OPTIONS, + }; let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) { - Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i), + Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1), None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1), None => None, }; @@ -145,8 +151,13 @@ fn password_from(args: &[String]) -> Result, String> { } /// The positional arguments, skipping every option's value so an option value is never read as a -/// path. Mirrors [`validate_options`], which has already checked the options are known. +/// path. Uses the same option specifications as [`validate_options`]. fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> { + positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect() +} + +/// Keep the original indices too, so [`text_args`] can open a positional path by its native name. +fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec { let mut out = Vec::new(); let mut i = 0; while let Some(arg) = args.get(i) { @@ -156,7 +167,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> i += 2; } else { if !arg.starts_with("--") { - out.push(arg); + out.push(i); } i += 1; } diff --git a/apps/wordcraft-cli/src/tests.rs b/apps/wordcraft-cli/src/tests.rs index 209fb546e7..844dda5689 100644 --- a/apps/wordcraft-cli/src/tests.rs +++ b/apps/wordcraft-cli/src/tests.rs @@ -211,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() { let ok = |raw: Vec| text_args(&raw).unwrap().1; assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1()))); assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1()))); assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1()))); assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None); // Every command that opens a file reaches the open with it (no such file here), so @@ -228,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() { for (raw, n) in [ (vec!["zotero".into(), latin1(), "in.docx".into()], 2), (vec!["render".into(), "in.docx".into(), latin1()], 3), + (vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3), (vec!["run".into(), "--save".into(), latin1()], 3), (vec!["commands".into(), latin1()], 2), ] { diff --git a/apps/wordcraft-cli/tests/it/non_utf8_args.rs b/apps/wordcraft-cli/tests/it/non_utf8_args.rs index fef340285b..032a28b09f 100644 --- a/apps/wordcraft-cli/tests/it/non_utf8_args.rs +++ b/apps/wordcraft-cli/tests/it/non_utf8_args.rs @@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn an_option_before_the_input_keeps_its_native_name() { + let (dir, file) = folder("option-first"); + let saved = dir.join("out.xml"); + ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()])); + assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour")); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn any_other_such_argument_is_an_error_naming_it() { let (dir, file) = folder("error"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ebbfa88077..86a3497870 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -2,6 +2,8 @@ > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 +> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. + > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the @@ -330,6 +332,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-05 | trivial | ODT: a frame offering one picture in several formats (LibreOffice's `.svm` metafile, then a PNG) shows the first WordCraft can read; it took the first whatever it was, and the picture was missing on screen and gone once the paragraph was rewritten An object's replacement picture coming before it is kept too. A readable picture after an unreadable replacement is shown, wherever the object comes, and a save from the model keeps both. | | 2026-11-04 | trivial | Engine field parsing and DOCX HYPERLINK targets consume quoted backslash escapes consistently | | 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks | +| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | diff --git a/docs/gaps.md b/docs/gaps.md index 5730b9254e..6413817c74 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Where WordCraft falls short of Word +> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); 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 (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; 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 (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); 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 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. @@ -193,6 +195,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-10-30 | trivial | #29: typed `\int`, `\sum` and the other n-ary operators wait for their limits; `_x^y` then Space builds them and the operand typed next goes inside; any character that can't continue `\name` corrects it (`\int_0^1`), observed in Word for Windows on #4938 (#4917); estimate unchanged | | 2026-10-17 | trivial | #10 narrowed: Drawing Canvas members are picked, moved, resized and formatted on their own (#2637); several at once and dragging one out of the canvas remain | +| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. | | 2026-11-05 | trivial | DOCX preserves `w:adjustRightInd` with its true default and writes it in schema order; grid layout remains open | | 2026-10-28 | trivial | Ink page lookups reuse scanned paragraph prefixes, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | | 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged | From cac121fd8969c71e077ba51433b73e857375ae50 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:52:26 +0100 Subject: [PATCH 15/38] Shading on text selected inside a paragraph shades the text, so No Color takes off an orange shading (#4685) (#4702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #4685: Shading on text selected inside a paragraph shades the text Home › Paragraph › Shading always set the paragraph's shading. Word shades the selected text when the selection is inside one paragraph (w:shd in the run properties), and the paragraph for a caret or a whole paragraph. So an orange text shading from a .docx (which Text Highlight Color › No Color leaves, in Word too) couldn't be taken off from the ribbon. Co-Authored-By: Claude Opus 5.5 * #4685: review — a paragraph's whole text is text unless it's the last; a selected object isn't text A selection with a paragraph's mark ends at the next paragraph's start, so 0..len of a paragraph that isn't the last of its story, cell or box is text only and No Color clears its shading. A selected text box or picture (its object character) shades the paragraph as before, instead of every line of the box. Co-Authored-By: Claude Opus 5.5 * #4685: review — a selected field is text; Apply to: Paragraph shades the paragraph Only a selected picture, shape or text box (`object_selection`) keeps Shading on the paragraph; a field's object character is text, so No Color clears its shading. The Borders and Shading dialog's Apply to: Paragraph passes `paragraph: true`, so it shades the paragraphs even for text selected inside one. Co-Authored-By: Claude Opus 5.5 * #4685: ui-parity wording (CodeRabbit): text shading applies to part of one paragraph Co-Authored-By: Claude Opus 5.5 * #4685: ui-parity wording (CodeRabbit): removes text shading imported from a file Co-Authored-By: Claude Opus 5.5 * UI parity: status line after merging main Co-Authored-By: Claude Opus 5.5 * #4685: a test of Shading in a cell's last paragraph Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- crates/engine/src/cmd/para.rs | 28 ++++++- crates/engine/src/tests.rs | 91 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/dialogs_para.rs | 7 +- docs/ui-parity.md | 3 +- 4 files changed, 126 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 2941925c5f..a96e16562c 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -183,9 +183,17 @@ pub fn specs() -> Vec { .key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }), CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| { let c = p::str(v, "color").and_then(Rgb::parse); + // Text selected within one paragraph is shaded itself, as in Word, so No Color takes + // off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs. + // `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway. + if p::bool(v, "paragraph") != Some(true) && shades_text(s) { + return super::format::apply(s, &|x| x.shading = c); + } fmt(s, &|p| p.shading = c) }) - .params(r#"{"color": "RRGGBB" | null}"#), + .params( + r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#, + ), CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params( r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#, ), @@ -264,3 +272,21 @@ pub fn specs() -> Vec { }), ] } + +/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one +/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's +/// start, so all of a paragraph's text is still text, except in the last paragraph of its story, +/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text +/// box is its object character, not text (a field's object character is). +fn shades_text(s: &Session) -> bool { + let (a, b) = s.sel.ordered(); + if a == b || a.story != b.story || a.path != b.path { + return false; + } + let Some(p) = s.doc.para_at(&a) else { return false }; + if super::objects::object_selection(s).is_some() { + return false; + } + let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent()); + !(last && a.off == 0 && b.off >= p.len()) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 316300fca9..bf37b90205 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7943,6 +7943,97 @@ fn colour_commands_take_any_custom_hex() { assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC")); } +/// #4685: Home › Paragraph › Shading on text selected inside a paragraph shades that text, as in +/// Word, so No Color takes off a text shading a file gave it (an orange "highlight" from a .docx is +/// `w:shd`, which Text Highlight Color › No Color leaves, in Word too). A caret or a whole +/// paragraph still shades the paragraph. +#[test] +fn shading_selected_text_shades_the_text_and_no_color_clears_it() { + let orange = wordcraft_doc::Rgb::parse("FFA500"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Orange text here"})); + run(&mut s, "select.text", json!({"text": "Orange"})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!((p.props.shading, p.props_of_char(0).shading, p.props_of_char(7).shading), (None, orange, None)); + // Highlight › No Color doesn't take it off; Shading › No Color does. + run(&mut s, "format.highlight", json!({"color": "none"})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, orange); + run(&mut s, "para.shading", json!({"color": null})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, None); + // A caret, or the whole paragraph, shades the paragraph and leaves the text alone. + for whole in [false, true] { + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "Orange text here"})); + run(&mut s, if whole { "select.all" } else { "caret.left" }, json!({})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None), "whole: {whole}"); + } + // All the text of a paragraph that isn't the last is still text: a selection with its mark + // would end at the next paragraph's start. + let mut s = self::s(); + run( + &mut s, + "document.setText", + json!({"text": "Word +More"}), + ); + run(&mut s, "select.text", json!({"text": "Word"})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!((p.props.shading, p.props_of_char(0).shading), (None, orange)); + // A selected text box shades its anchor's paragraph, not every line of the box. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "Anchor"})); + run(&mut s, "insert.textBox", json!({"text": "Inside"})); + let id = s.doc.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).map(|(id, _)| *id).unwrap(); + let end = s.doc.text_box_anchor(id).unwrap(); + let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() }; + run(&mut s, "select.range", json!({"anchor": start, "focus": end})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let boxed = s.doc.parts.get(&id).and_then(|p| p.blocks.first()).and_then(|b| b.as_para()).unwrap(); + assert_eq!(boxed.props_of_char(0).shading, None, "the box's text isn't shaded"); + assert_eq!(s.doc.para_at(&end).unwrap().props.shading, orange); + // A selected field is text (its object character isn't a drawing): No Color clears its shading. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "Page "})); + run(&mut s, "insert.pageNumber", json!({"position": "current"})); + let end = s.sel.focus.clone(); + let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() }; + run(&mut s, "select.range", json!({"anchor": start, "focus": end})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let p = s.doc.para_at(&end).unwrap(); + assert_eq!((p.props.shading, p.props_of_char(start.off).shading), (None, orange), "the field is shaded"); + run(&mut s, "para.shading", json!({"color": null})); + assert_eq!(s.doc.para_at(&end).unwrap().props_of_char(start.off).shading, None); + // Borders and Shading › Apply to: Paragraph shades the paragraph, whatever is selected. + run(&mut s, "select.text", json!({"text": "Page"})); + run(&mut s, "para.shading", json!({"color": "FFA500", "paragraph": true})); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None)); +} + +/// #4685 review: in a cell's last paragraph all of its text stands for the paragraph with its +/// mark (a selection can't reach the next paragraph's start there), so Shading shades the +/// paragraph; part of the text is text. +#[test] +fn shading_all_of_a_cells_last_paragraph_shades_the_paragraph() { + let orange = wordcraft_doc::Rgb::parse("FFA500"); + for (from, to, paragraph) in [(0, 9, true), (0, 4, false)] { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "Cell text"})); + let cell = s.sel.focus.clone(); + assert!(cell.path.cell().is_some()); + run(&mut s, "select.range", json!({"anchor": Pos { off: from, ..cell.clone() }, "focus": Pos { off: to, ..cell.clone() }})); + run(&mut s, "para.shading", json!({"color": "FFA500"})); + let p = s.doc.para_at(&cell).unwrap(); + let want = if paragraph { (orange, None) } else { (None, orange) }; + assert_eq!((p.props.shading, p.props_of_char(0).shading), want, "{from}..{to}"); + } +} + /// #2738: Home › Underline › Underline Color sets the underline's own colour and leaves its style /// alone; `auto` drops it again. One undo step each, kept through a DOCX round trip, and junk is /// a params error that changes nothing. diff --git a/crates/ui-egui/src/dialogs/dialogs_para.rs b/crates/ui-egui/src/dialogs/dialogs_para.rs index 63bbcb809a..59a348ada0 100644 --- a/crates/ui-egui/src/dialogs/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs/dialogs_para.rs @@ -534,7 +534,12 @@ impl BordersForm { } if self.fill != b.fill || self.fill_to_text != b.fill_to_text { let color = Rgb::parse(&self.fill).map(|c| json!(c.hex())).unwrap_or(Value::Null); - out.push((if self.fill_to_text { "format.shading" } else { "para.shading" }, json!({"color": color}))); + // Apply to: Paragraph shades the paragraphs even for text selected inside one. + out.push(if self.fill_to_text { + ("format.shading", json!({"color": color})) + } else { + ("para.shading", json!({"color": color, "paragraph": true})) + }); } out } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b8e71abcc6..03247ffc81 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,9 +19,9 @@ Document title opens Rename Document; the account badge opens Options › Genera | Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included. Home › Font: the font box lists Recently Used Fonts (the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, ten at most, kept between runs) above All Fonts, as in Word; the sections show while nothing is typed, including after a no-match query is cleared in a scrollable list (#2068). With Filling in forms enforced (Restrict Editing), the Developer tab's content-control buttons and Properties grey out, their tooltips saying why, as in Word. Read only and comments-only protection without an exception the author may edit grey out every edit, their tooltips saying why, and tracked-changes protection greys out Accept, Reject and Track Changes; what decides it is cached per document revision, so the ribbon doesn't walk the document on every frame | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / F10) | Ribbon badges (#43), Backstage page badges after Alt, F (H, N, O, I, S, A, P, E, C, T; P and V open Print, #4960) and numbered Quick Access Toolbar badges in the user's button order (#620; none while the toolbar is hidden, #1945); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone, and AltGr leaves typing alone on Linux and Windows (#2388); legacy menu keys (#1697; not on macOS): 18 Word 2003 sequences Word for Windows still takes, as observed there (Alt, V, U Full Screen View; V, P/W/O/N views; V, D/L/Z; E, L/F/E/G; I, S; I, P, F; O, F/P; T, W; A, I, T), hidden, with a hint under the tab row (under the slim bar in Focus and Full Screen View); an unknown letter is ignored, Esc cancels; Alt, I, B left out (no Break dialog) | ✅ | 85% | 1–2 | | Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 | +| Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading (text selected within part of one paragraph is shaded itself; a caret, whole paragraphs or several paragraphs keep paragraph shading, as in Word, #4685), Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); Shape Fill › Gradient: Light and Dark Variations of the shape's colour, More Gradients… (#1500); no eyedropper | ✅ | 90% | 1 | | Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620; none while the toolbar is hidden, #1945); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone, and AltGr leaves typing alone on Linux and Windows (#2388); legacy menu keys (#1697; not on macOS): 18 Word 2003 sequences Word for Windows still takes, as observed there (Alt, V, U Full Screen View; V, P/W/O/N views; V, D/L/Z; E, L/F/E/G; I, S; I, P, F; O, F/P; T, W; A, I, T), hidden, with a hint under the tab row (under the slim bar in Focus and Full Screen View); an unknown letter is ignored, Esc cancels; Alt, I, B left out (no Break dialog) | ✅ | 85% | 1–2 | | Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; hovering a formatting button previews it on the selected text, #4664; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 | -| Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); Shape Fill › Gradient: Light and Dark Variations of the shape's colour, More Gradients… (#1500); no eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Its menu ends with Hide Quick Access Toolbar, which takes it out of the title bar; Show Quick Access Toolbar on the ribbon's right-click menu, or in Customize Ribbon › Quick Access Toolbar, brings it back; kept between runs; `ui.quickAccessToolbar` for scripts (#1945). Not shown below the ribbon | customizable | 92% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file; filter-as-you-type command search in all three pickers (#1845) | ✅ | 85% | 1–2 | | Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); 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). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | @@ -109,6 +109,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | +| 2026-10-28 | trivial | Home › Paragraph › Shading: text selected within part of one paragraph is shaded itself, as in Word, so No Color removes text shading imported from a file (#4685) | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | From bc0f691c139a395280ce9ec74c656799bc175ccf Mon Sep 17 00:00:00 2001 From: Morzilla Date: Sun, 8 Nov 2026 02:54:47 +0100 Subject: [PATCH 16/38] Layout: a picture lowered past its height lowers its line as far, as in Word (#5796) * Layout: a picture lowered past its height lowers its line as far, as in Word A picture lowered further than its own height had its lowering cut at that height. Word keeps its top on the baseline but lowers the line as far as the position says: a 6 pt picture lowered 30 pt makes a 30 pt line. Exact spacing keeps the line's baseline. Closes the gap noted with #3984: all 156 measured lines now match Word. * Lowered pictures: an exact-spacing test; gaps and progress log An exact-spacing line keeps its baseline when a picture is lowered past its height (the test fails without the cap). Gap 4 and the progress log note the change. --------- Co-authored-by: Daniele Mori Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/display/lines.rs | 2 +- crates/layout/src/display/objects.rs | 3 ++- crates/layout/src/para/lines.rs | 6 +++-- crates/layout/src/para/paragraph.rs | 11 +++++---- crates/layout/src/tests.rs | 37 +++++++++++++++++++++++++--- docs/gaps.md | 3 ++- docs/layout-parity.md | 3 ++- 8 files changed, 53 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b162eef38a..502426c45d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. - 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. +- 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -824,6 +825,7 @@ MATH glyphs that reference the same construction now share its immutable variant |---|---|---| | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | +| 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index 7068e855a4..e888dedfbb 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -656,7 +656,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { Some(o @ InlineObject::Field { .. }) => { if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() { let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None)); - let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d); + let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d); check_box(frame, checked, color, alpha, out); } } diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index 1cc37988df..5e0b04e6e1 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -491,7 +491,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, 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 + obj_d - t - b).max(0.0)); + // Lowered further than its height, it is drawn with its top on the baseline (Word). + let room = Rect::new(cx + l, base - obj_h.max(0.0) + t, (adv - l - r).max(0.0), (obj_h + obj_d - 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`). diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index fbc4947e34..9edfeb2b7f 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -438,8 +438,10 @@ impl<'e> Breaker<'e> { if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) } } else { ex.obj_asc = ex.obj_asc.max(c.obj_h); - // A lowered picture reaches below the baseline. - ex.obj_desc = ex.obj_desc.max(c.obj_d); + // A lowered picture reaches below the baseline, with exact spacing no further + // than its height (the line's box is fixed; its baseline stays where it was). + let reach = if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { c.obj_d.min(c.obj_h + c.obj_d) } else { c.obj_d }; + ex.obj_desc = ex.obj_desc.max(reach); (st.ascent, st.descent) } } else { diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index b712f9dce5..07a75bef44 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -338,7 +338,8 @@ impl<'a> Shaped<'a> { /// A picture, shape or text box: inline, as wide and high as it is; a floating one takes no room. /// An inline drawing: it stands on the baseline, raised or lowered by its run's position /// (`raise`, points, as Word moves an inline picture with `w:position`). Its cluster reaches - /// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it). + /// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it; `obj_h` + /// negative when lowered further than its height, drawn with its top on the baseline). fn drawing(&mut self, cx: &Cx, (k, o): (usize, &InlineObject), slot: Slot, raise: f32) { let env = cx.env; // A hidden one takes no room, and with no height its line doesn't draw it. @@ -368,10 +369,10 @@ impl<'a> Shaped<'a> { let [el, et, er, eb] = float.effect_extent(); let (px, py) = float.spin_pad(w * s, h * s); let tall = h * s + et + eb + 2.0 * py; - // Lowered at most until its top is on the baseline. The bound is made finite and - // not above `clamp`'s top whatever the extents give (`clamp` panics otherwise). - let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 }; - let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 }; + // Lowered further than its height, its top stays on the baseline while the line + // reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt + // makes a 30 pt line); `obj_h` is then negative. Word's positions run to 1584 pt. + let raise = if raise.is_finite() { raise.clamp(-1584.0, 1584.0) } else { 0.0 }; self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise))); } else { self.push(slot, ClKind::Object(k), 0.0, 0.0); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 45612e2222..ca0805afc7 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5598,8 +5598,13 @@ fn an_inline_picture_moves_with_its_runs_position() { assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}"); let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv); assert_eq!((r.y, r.h), (100.0, 30.0)); - // Lowered further than that: still only to its top. - assert_eq!(line_of(-500.0).0.obj_d, 30.0); + // Lowered further than that: the line reaches as far down, the picture's top staying on the + // baseline (Word). + let (c, line, _) = line_of(-500.0); + assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0)); + assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}"); + let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv); + assert_eq!((r.y, r.h), (100.0, 30.0)); // Raised: it stands clear of the baseline, as tall as before. let (c, _, _) = line_of(6.0); assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0)); @@ -13694,7 +13699,7 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() { let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap(); let (line, st) = (¶.lines[0], ¶.styles[c.style as usize]); - (line.height, line.baseline - c.obj_h - line.top, st.ascent + st.descent) + (line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent) }; for m in [1.0, 1.15, 1.5] { let share = |font: f32| (m - 1.0) * font; @@ -13711,10 +13716,36 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() { let (h, top, font) = line_of("", m, 6.0, raise); assert!((h - font * m).abs() < 0.01 && (top - (font - reach)).abs() < 0.01, "{raise} at {m}: {h}, {top}"); } + // Lowered further than its height: as far down as it's lowered, the font's height at least, + // its top on the baseline (Word: a 6 pt picture lowered 10 pt takes the font's 11.5 pt, + // lowered 30 pt 30 pt; beside text, the text's ascent and the 30 pt). + let (h, top, font) = line_of("", m, 6.0, -10.0); + assert!((h - font * m).abs() < 0.01 && (top - (font - 10.0)).abs() < 0.01, "6 lowered 10 at {m}: {h}, {top}"); + let (h, top, font) = line_of("", m, 6.0, -30.0); + assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "6 lowered 30 at {m}: {h}, {top}"); + let (h, top, font) = line_of("x", m, 6.0, -30.0); + let asc = h - 30.0 - share(font); + assert!(asc > 0.0 && asc < font && (top - asc).abs() < 0.01, "6 lowered 30 beside text at {m}: {h}, {top}"); // Beside text, lowered: the text's ascent is no matter, the picture reaching further. let (h, top, font) = line_of("x", m, 30.0, -10.0); assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "beside text at {m}: {h}, {top}"); } + // Exact spacing keeps the line's box and its baseline: a picture lowered past its height sits + // where one lowered by its height does. + let exact = |raise: f32| { + let mut d = Document::from_text("x"); + let props = wordcraft_doc::CharProps { position: Some(raise), ..Default::default() }; + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.line_spacing = Some(LineSpacing::Exactly(20.0)); + let at = p.len(); + p.insert_object(at, picture(40.0, 6.0, Default::default()), &props).unwrap(); + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + (para.lines[0].height, para.lines[0].baseline - para.lines[0].top) + }; + let (h, base) = exact(-6.0); + assert!((h - 20.0).abs() < 0.01, "{h}"); + assert_eq!(exact(-30.0), (h, base)); // A check box sizes its line as text of its size, alone or beside text. let box_line = |lead: &str, m: f32, size: u32| { let mut d = Document::from_text(lead); diff --git a/docs/gaps.md b/docs/gaps.md index 6413817c74..173a8d8db9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -80,7 +80,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren |---|---|---|---|---|---|---| | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), shape gradients and transparency (#1500), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers, and `flowChartOr` drawn with its spokes (#1644); explicit DrawingML outline dashes and flat/square/round caps preserved; paragraph-relative header/footer/cell/text-box float origins corrected (cu20we, synthetic regressions) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | -| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | In-line pictures raised or lowered (`w:position`) set their line's height as in Word, past their own height too (#3984, #5796). SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 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) | @@ -188,6 +188,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | | 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed | +| 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 22f362923f..b3bb97b603 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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 (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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 (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; 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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -298,6 +298,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc |---|---|---| | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | +| 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From b61aee61ba07c18bf1a7a38ca785dd9536c3fd63 Mon Sep 17 00:00:00 2001 From: TheRenegadeDev Date: Sat, 7 Nov 2026 19:55:02 -0600 Subject: [PATCH 17/38] =?UTF-8?q?Styles=20pane:=20the=20scroll=20bar=20no?= =?UTF-8?q?=20longer=20covers=20the=20a=20/=20=C2=B6=20markers=20(#4753)?= =?UTF-8?q?=20(#4882)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #4753: Styles pane scroll bar keeps clear of the a / ¶ markers The Styles pane list used egui's default floating scroll bar, which is drawn over the right edge of the rows; on hover it widens to 10 pt with a translucent track and covers the right-aligned "a" and "¶" style-type markers. The list now uses a solid scroll bar, which takes a column of its own beside the rows (as in the TOC options dialog and vectorcraft#2232's menus). Its handle keeps the text colour: the solid preset fills it with the button colour, which is white on the pane's white track and would leave the handle invisible. The regression test draws the pane with the app theme, hovers the bar and checks that no scroll bar rectangle meets a marker and that the handle stands out from the track. Co-Authored-By: Claude Opus 5.5 * #4753: Styles pane scroll bar test hovers the bar and checks dark too The test now takes the hover point from the pane's own right edge instead of the harness size (kittest insets the UI, so the old point sat just outside the bar), asserts the pointer is on the bar, and runs in the dark appearance as well as the light one. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- crates/ui-egui/src/panes/side.rs | 3 ++ crates/ui-egui/src/panes/tests.rs | 56 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/ui-egui/src/panes/side.rs b/crates/ui-egui/src/panes/side.rs index 7c4d3a26dd..fd2984f9d3 100644 --- a/crates/ui-egui/src/panes/side.rs +++ b/crates/ui-egui/src/panes/side.rs @@ -72,6 +72,9 @@ pub(super) fn styles(app: &mut WordApp, ui: &mut Ui) { if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() { let _ = app.run("styles.inspector", json!({})); } + // A solid scroll bar takes its own room beside the list instead of floating over the a / ¶ markers. + // Its handle keeps the text colour: the solid default fills it like a button, white on white here. + ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() }; ui.separator(); let mut list: Vec<(String, String, bool, bool)> = app .session diff --git a/crates/ui-egui/src/panes/tests.rs b/crates/ui-egui/src/panes/tests.rs index 31662bb0d5..d6597c3729 100644 --- a/crates/ui-egui/src/panes/tests.rs +++ b/crates/ui-egui/src/panes/tests.rs @@ -468,3 +468,59 @@ fn a_long_comment_card_keeps_its_buttons_and_the_next_card_in_view() { assert_eq!(go_to.len(), 2, "one Go to button per card"); assert!(go_to.iter().all(|r| r.max.y < 900.0), "both cards' buttons are in view: {go_to:?}"); } + +/// #4753: the Styles pane's scroll bar has a column of its own, so with the pointer over it the +/// "a" and "¶" markers at the right of each row stay clear of it; its handle still shows, light +/// or dark. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_styles_pane_scroll_bar_leaves_the_style_markers_clear() { + use egui_kittest::kittest::Queryable; + for appearance in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] { + let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + let size = egui::vec2(240.0, 400.0); + let right = std::rc::Rc::new(std::cell::Cell::new(0.0)); + let edge = right.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + move |ui, app: &mut crate::WordApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), appearance); + fonts = true; + return; + } + edge.set(ui.max_rect().right()); + super::styles(app, ui); + }, + app, + ); + h.run_steps(4); + // On the bar, along the pane's right edge, where it is at its widest. + h.hover_at(egui::pos2(right.get() - 3.0, size.y - 60.0)); + h.run_steps(20); + let markers: Vec = h.get_all_by_label("¶").chain(h.get_all_by_label("a")).map(|n| n.rect()).collect(); + assert!(markers.len() > 5, "{appearance:?}: the list shows its markers: {markers:?}"); + // Only a scroll bar is a narrow, tall filled rectangle along the list's right edge. + let bars: Vec<(egui::Rect, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill.a() > 0 && r.rect.width() < 20.0 && r.rect.height() > 3.0 * r.rect.width() => { + Some((r.rect.intersect(c.clip_rect), r.fill)) + } + _ => None, + }) + .collect(); + assert!(bars.iter().any(|(b, _)| b.x_range().contains(right.get() - 3.0)), "{appearance:?}: the pointer is on the bar: {bars:?}"); + let covered: Vec<_> = markers.iter().filter(|m| bars.iter().any(|(b, _)| b.intersects(**m))).collect(); + assert!(covered.is_empty(), "{appearance:?}: the scroll bar {bars:?} covers markers {covered:?}"); + // The track is the pane's field colour; the handle stands out from it. + let track = h.ctx.global_style().visuals.extreme_bg_color; + assert!( + bars.iter().any(|(_, fill)| fill.r().abs_diff(track.r()) > 60), + "{appearance:?}: the handle shows against the track {track:?}: {bars:?}" + ); + } +} From f7e060ac3fe9572d4c3f6232b0c78daeada72522 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:57:32 +0100 Subject: [PATCH 18/38] Escape a heading's trailing '#' on Markdown export (#4808) Found while testing crates/formats/src/markdown/export.rs:377 (write_blocks, Kind::Heading arm) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Martin Ball --- crates/formats/src/markdown/export.rs | 16 ++++++++++++++-- crates/formats/src/markdown/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 0238686c1e..08f2c782a2 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -330,6 +330,18 @@ pub fn export(doc: &Document) -> String { s } +/// A heading's text on one line, with a trailing run of '#' escaped so a reader does not take it +/// for the optional closing sequence (`## Item #` reads back as "Item"). +fn heading_text(body: &str) -> String { + let mut s = body.replace("\\\n", " "); + let t = s.trim_end(); + let core = t.trim_end_matches('#'); + if core.len() != t.len() && (core.is_empty() || core.ends_with([' ', '\t'])) { + s.insert(core.len(), '\\'); + } + s +} + pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec) { let mut i = 0; let mut counters = [0u32; 9]; @@ -410,8 +422,8 @@ pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec) { None => { prev_list = false; let line = match p.kind { - Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")), - Kind::Title => format!("# {}", body.replace("\\\n", " ")), + Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), heading_text(&body)), + Kind::Title => format!("# {}", heading_text(&body)), Kind::Rule => "---".to_string(), Kind::Quote => { let b = escape_lines(body); diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 1a6f520c99..24bbea36ee 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -775,3 +775,25 @@ fn exported_link_destinations_read_back_unchanged() { assert_eq!(md("https://example.com/a b"), "[x]()"); assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)"); } + +#[test] +fn heading_trailing_hash_survives_round_trip() { + let heading = |md: &str| { + let f = parse(md); + let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("no paragraph in {md:?}") }; + assert_eq!(p.kind, Kind::Heading(2), "{md:?}"); + p.text() + }; + for (src, text, want) in [ + ("## Item \\#\n", "Item #", "## Item \\#\n"), + ("## Issue \\##\n", "Issue ##", "## Issue \\##\n"), + ("## \\#\n", "#", "## \\#\n"), + ("## C#\n", "C#", "## C#\n"), + ] { + assert_eq!(heading(src), text, "{src:?}"); + let md = export(&import(src)); + assert_eq!(md, want, "first export of {src:?}"); + assert_eq!(heading(&md), text, "md was {md:?}"); + assert_eq!(export(&import(&md)), want, "second export of {src:?}"); + } +} From 750e26657eb5a735423247540b80ea474abb0271 Mon Sep 17 00:00:00 2001 From: TheRenegadeDev Date: Sat, 7 Nov 2026 20:00:05 -0600 Subject: [PATCH 19/38] Navigation pane: test that the tabs fit the narrowest pane in every language (#4482) (#4883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #4482: test that every Navigation tab fits the narrowest pane in every language The new kittest draws the Navigation pane body at the 200 pt minimum width with the longest translation of each tab from craft-i18n (Überschriften, Страницы, Eredmények) as a fixture language, so it runs in CI without the catalogs. With the catalogs built in, it also checks every interface language. Each tab must lie inside the 178 pt body. Main already wraps the tabs onto a second row, so the test passes there. It fails on a single-row tab strip that doesn't wrap, such as the one in #4480. Co-Authored-By: Claude Opus 5.5 * #4482: draw the tab test in the app's style; catch a stale fixture The test now applies the app theme with the fonts. With the catalogs built in, it also fails when any language's tab label is wider than the fixture's, so a craft-i18n bump that brings a longer label asks for the fixture to be updated. Co-Authored-By: Claude Opus 5.5 * #4482: the tab test fails when a tab elides its label to fit Co-Authored-By: Claude Opus 5.5 * #4482: the tab test puts English back even when an assertion fails Co-Authored-By: Claude Opus 5.5 * #4482: the stale-fixture failure says which label to put in the fixture Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panes/tests.rs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/ui-egui/src/panes/tests.rs b/crates/ui-egui/src/panes/tests.rs index d6597c3729..09d165db8e 100644 --- a/crates/ui-egui/src/panes/tests.rs +++ b/crates/ui-egui/src/panes/tests.rs @@ -309,6 +309,78 @@ fn pane_bodies_fit_the_narrowest_pane() { assert!(wide.is_empty(), "wider than the pane: {wide:?}"); } +/// #4482: at the 200 pt minimum width every Navigation tab is whole, in the longest labels. The +/// fixture puts the longest translation of each tab in craft-i18n (at `craft-i18n.rev`) side by +/// side; with the catalogs built in, every interface language is checked too, and none may have +/// a wider tab than the fixture (else the fixture needs the new longest label). +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn navigation_tabs_fit_the_narrowest_pane_in_every_language() { + use egui_kittest::kittest::Queryable; + // The language is thread-local: put English back even when an assertion fails. + struct English; + impl Drop for English { + fn drop(&mut self) { + crate::i18n::set_current(crate::i18n::Lang::EN); + } + } + let _english = English; + // 200 pt less the 10 pt body margins and the frame's 1 pt strokes. + const BODY: f32 = 178.0; + let longest = crate::i18n::fixture_lang("x-fixture", "\tHeadings\tÜberschriften\n\tPages\tСтраницы\n\tResults\tEredmények\n"); + let mut widest = Vec::new(); + let mut langs = vec![longest]; + if crate::i18n::catalogs_embedded() { + // Chinese, Japanese and Korean need a craft-fonts build (their labels are short anyway). + let cjk = !wordcraft_fonts::ui_cjk_fonts(false).is_empty(); + langs.extend(crate::i18n::Lang::all().filter(|l| cjk || !l.uses_cjk())); + } + for (n, lang) in langs.into_iter().enumerate() { + let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + let body = std::rc::Rc::new(std::cell::Cell::new(egui::Rect::NOTHING)); + let seen = body.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 600.0)).build_ui_state( + move |ui, app: &mut crate::WordApp| { + crate::i18n::set_current(lang); + // Fonts take effect the frame after they are installed. + if !fonts { + crate::theme::apply(ui.ctx(), crate::theme::Appearance::Light); + crate::theme::install_fonts_language(ui.ctx(), lang.prefers_hans(), lang.prefers_korean()); + fonts = true; + return; + } + let r = ui.allocate_ui_with_layout(vec2(BODY, 600.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_max_width(BODY); + Content { app: &mut *app }.body(ui, &Pane::Navigation); + }); + seen.set(egui::Rect::from_min_size(r.response.rect.min, vec2(BODY, 600.0))); + }, + app, + ); + h.run_steps(3); + for (i, tab) in ["Headings", "Pages", "Results"].into_iter().enumerate() { + let label = crate::i18n::tr(lang, tab); + let r = h.get_by_label(label).rect(); + assert!(body.get().expand(0.5).contains_rect(r), "{}: {label} at {r:?} is cut off by the {BODY} pt body", lang.code()); + let font = egui::TextStyle::Button.resolve(&h.ctx.global_style()); + let text = h.ctx.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x); + // A tab that shrank to fit would have elided or wrapped its label inside a rect that + // still fits the body. + assert!(r.width() + 0.5 >= text, "{}: {label} is {:.1} pt wide, narrower than its {text:.1} pt label", lang.code(), r.width()); + if n == 0 { + widest.push(text); + } else { + assert!( + text <= widest[i] + 0.5, + "{}: {label} is wider than the fixture's {tab} label; put {label:?} in the fixture as the longest {tab}", + lang.code() + ); + } + } + } +} + /// A pane other than Styles floated from its menu comes back floating, at the same rect, after /// a restart; junk in `panePlaces` reads as the defaults (#1756). #[test] From 6d3b51fab1913a4c71077d4dd88083fbfdbe9e29 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:00:23 +0100 Subject: [PATCH 20/38] Decode HTML character references written without ';' (#4820) Found while testing crates/formats/src/html/entity.rs:250-251 (unescape) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Martin Ball --- crates/formats/src/html/entity.rs | 72 ++++++++++++++++++++++++++++--- crates/formats/src/html/lexer.rs | 6 +-- crates/formats/src/html/tests.rs | 15 +++++++ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/crates/formats/src/html/entity.rs b/crates/formats/src/html/entity.rs index 09b0173517..a77e771117 100644 --- a/crates/formats/src/html/entity.rs +++ b/crates/formats/src/html/entity.rs @@ -3,6 +3,10 @@ /// Decode an entity body (`amp`, `#38`, `#x26`) to its text. pub fn decode_entity(name: &str) -> Option { + decode_char(name).map(String::from) +} + +fn decode_char(name: &str) -> Option { if let Some(num) = name.strip_prefix('#') { let (digits, radix) = match num.strip_prefix(['x', 'X']) { Some(h) => (h, 16), @@ -17,7 +21,7 @@ pub fn decode_entity(name: &str) -> Option { 0x80..=0x9F => crate::txt::cp1252(v as u8), _ => char::from_u32(v).unwrap_or('\u{FFFD}'), }; - return Some(c.to_string()); + return Some(c); } let c = match name { "amp" | "AMP" => '&', @@ -229,11 +233,54 @@ pub fn decode_entity(name: &str) -> Option { "zwnj" => '\u{200C}', _ => return None, }; - Some(c.to_string()) + Some(c) } -/// Replace entity references in `s`. +/// Legacy names HTML5 also decodes without a `;` (only those [`decode_char`] knows), sorted by bytes. +const BARE_NAMES: &[&str] = &[ + "AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute", + "Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave", + "Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide", + "eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt", + "micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg", + "sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml", +]; + +/// A reference written without its `;` at the start of `after` (the text after `&`): a numeric one, +/// or the longest legacy name. In attribute values a name followed by `=` or an alphanumeric stays text. +fn bare_reference(after: &str, attr: bool) -> Option<(char, usize)> { + if let Some(num) = after.strip_prefix('#') { + let hex = num.starts_with(['x', 'X']); + let radix = if hex { 16 } else { 10 }; + let skip = usize::from(hex); + let n = num.get(skip..)?.bytes().take_while(|b| char::from(*b).is_digit(radix)).count(); + if n == 0 { + return None; + } + let len = 1 + skip + n; + return decode_char(after.get(..len)?).map(|c| (c, len)); + } + // Legacy names are 2 to 6 ASCII alphanumerics: try the longest prefix first. + let run = after.bytes().take(6).take_while(u8::is_ascii_alphanumeric).count(); + let len = (2..=run).rev().find(|&l| after.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?; + let next = after.as_bytes().get(len); + if attr && next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') { + return None; + } + decode_char(after.get(..len)?).map(|c| (c, len)) +} + +/// Replace entity references in text `s`. pub fn unescape(s: &str) -> String { + unescape_in(s, false) +} + +/// Replace entity references in an attribute value `s`. +pub fn unescape_attr(s: &str) -> String { + unescape_in(s, true) +} + +fn unescape_in(s: &str, attr: bool) -> String { if !s.contains('&') { return s.to_string(); } @@ -248,10 +295,10 @@ pub fn unescape(s: &str) -> String { cut -= 1; } let end = after.get(..cut).and_then(|w| w.find(';')); - match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) { - Some((t, e)) => { - out.push_str(&t); - rest = &after[e + 1..]; + match end.and_then(|e| decode_char(&after[..e]).map(|c| (c, e + 1))).or_else(|| bare_reference(after, attr)) { + Some((c, e)) => { + out.push(c); + rest = &after[e..]; } None => { out.push('&'); @@ -310,3 +357,14 @@ pub fn decode_as(b: &[u8], charset: Option<&str>) -> String { }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_names_are_sorted_and_known() { + assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1])); + assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && decode_char(n).is_some())); + } +} diff --git a/crates/formats/src/html/lexer.rs b/crates/formats/src/html/lexer.rs index a550f54ba8..d37bf36f9f 100644 --- a/crates/formats/src/html/lexer.rs +++ b/crates/formats/src/html/lexer.rs @@ -1,4 +1,4 @@ -use super::entity::unescape; +use super::entity::{unescape, unescape_attr}; // --------------------------------------------------------------------------------------------- // Tokenizer @@ -154,7 +154,7 @@ impl Lexer<'_> { Some(&q) if q == b'"' || q == b'\'' => { let v0 = i + 1; let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len()); - val = unescape(rest.get(v0..e).unwrap_or("")); + val = unescape_attr(rest.get(v0..e).unwrap_or("")); i = e + 1; } _ => { @@ -162,7 +162,7 @@ impl Lexer<'_> { while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') { i += 1; } - val = unescape(rest.get(v0..i).unwrap_or("")); + val = unescape_attr(rest.get(v0..i).unwrap_or("")); } } } diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index 3281dcc237..ba49057f80 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -232,6 +232,21 @@ fn entities() { assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"); } +/// Browsers decode numeric and legacy named references without their `;` in text, +/// but keep `&name=` and `&nameX` intact inside attribute values (query strings). +#[test] +fn references_without_semicolon() { + assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme"); + assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme"); + assert_eq!(unescape("© 2024 & <"), "© 2024 & <"); + assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &"); + assert_eq!(unescape(" x"), "\u{00A0}x"); + let f = parse("

l © 2024

"); + let FBlock::Para(p) = &f.blocks[0] else { panic!() }; + assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1©=2®2=3&b=<")))); + assert!(p.text().ends_with("© 2024")); +} + #[test] fn wbr_word_break_opportunity() { let f = parse("

Supercalifragilistic

"); From 73356ed82a82dfa824c1c1e58cabb2ad431ed20c Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sun, 8 Nov 2026 08:02:42 +0600 Subject: [PATCH 21/38] Styles gallery: reads from the right in a right-to-left interface (#5566) (#5708) In the mirrored Arabic interface the Home tab's Styles gallery still laid its tiles out left to right with the arrows on the right, so Normal sat at the far end. Tiles now start at the right edge and the up/down/more arrows sit at the left; left-to-right is unchanged. The "more" button gets the Styles Pane label it opens, so screen readers can name it. --- ROADMAP.md | 4 +- crates/ui-egui/src/previews.rs | 12 ++++- crates/ui-egui/src/previews/tests.rs | 66 ++++++++++++++++++++++++++++ docs/localization-parity.md | 3 +- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 502426c45d..cb78163f4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,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-06 · **Change:** trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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-06 · **Change:** trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; 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 (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 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged. - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. - 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. @@ -823,6 +824,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 010601fe86..92bb56273f 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -582,10 +582,13 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); + // A mirrored interface (#5566) starts the tiles at the right and puts the arrows at the left. + let rtl = crate::rtl::flows(ui); let (area, _) = ui.allocate_exact_size(vec2(visible as f32 * 76.0 + 18.0, crate::widgets::CONTENT_H), Sense::hover()); ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() { - let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0)); + let x = crate::rtl::lead_left(rtl, area, 3.0 + k as f32 * 76.0, 74.0); + let r = Rect::from_min_size(pos2(x, area.min.y + 3.0), vec2(74.0, area.height() - 6.0)); let resp = ui.interact(r, ui.scope_id().with(("style_tile", id)), Sense::click()); let active = *id == current; resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, name)); @@ -637,11 +640,16 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { } } // Scroll arrows and "more". - let ar = Rect::from_min_max(pos2(area.max.x - 16.0, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0)); + let ax = crate::rtl::lead_left(!rtl, area, 2.0, 14.0); + let ar = Rect::from_min_max(pos2(ax, area.min.y + 2.0), pos2(ax + 14.0, area.max.y - 2.0)); let thirds = ar.height() / 3.0; for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() { let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds)); let resp = ui.interact(br, ui.scope_id().with(("gal_arrow", i)), Sense::click()); + if *delta == 0 { + // "More" opens the Styles pane: named for it, for screen readers. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Styles Pane"))); + } if resp.hovered() { ui.painter().rect_filled(br, 2.0, t.hover); } diff --git a/crates/ui-egui/src/previews/tests.rs b/crates/ui-egui/src/previews/tests.rs index 4a3a102f8e..31066daf38 100644 --- a/crates/ui-egui/src/previews/tests.rs +++ b/crates/ui-egui/src/previews/tests.rs @@ -382,6 +382,72 @@ fn font_menu_entries_take_the_theme_text_colour() { } } +/// Where the Home tab's Styles group, laid out as the ribbon lays it out in the current interface +/// language, puts its parts, by their accessible rectangles: the gallery's first tiles in gallery +/// order, the column of scroll arrows and "more" from the top (the unnamed buttons beside the +/// tiles; only "more" has a name), and the button named for the Styles pane. +fn styles_group_rects() -> (Vec<(String, Rect)>, Vec, Option) { + use egui_kittest::kittest::{Queryable, by}; + let app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default()); + let names: Vec = app.session.doc.styles.gallery().iter().take(super::STYLE_TILES).map(|s| crate::i18n::style_name(s)).collect(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 200.0)).build_ui_state( + |ui, app: &mut WordApp| { + crate::rtl::root(ui, |ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::widgets::group(ui, "Styles", Some("styles.pane"), app, |ui, app| style_gallery(app, ui, &json!({}))); + }); + }); + }, + app, + ); + h.run_steps(3); + let tiles: Vec<(String, Rect)> = names.iter().map(|n| (n.clone(), h.get_by_label(n).rect())).collect(); + let row = tiles.first().map_or(Rect::NOTHING, |(_, r)| *r); + // The arrows are 14 points wide and stacked beside the tiles; the group's launcher is below them. + let mut arrows: Vec = h + .query_all(by().predicate(|n| n.bounding_box().is_some())) + .map(|n| n.rect()) + .filter(|r| r.width() <= 16.0 && (row.min.y..=row.max.y).contains(&r.center().y)) + .collect(); + arrows.sort_by(|a, b| a.min.y.total_cmp(&b.min.y)); + let more = h.query_by_label(crate::i18n::t("Styles Pane")).map(|n| n.rect()); + (tiles, arrows, more) +} + +/// #5566: in a right-to-left interface the Styles gallery starts at the right, as the rest of +/// the ribbon does: the first style (Normal) is the rightmost tile, and the scroll arrows and +/// "more" sit at the gallery's left end. Left to right is unchanged. +#[test] +fn the_styles_gallery_reads_from_the_right_in_a_right_to_left_interface() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let (tiles, arrows, more) = styles_group_rects(); + assert!(tiles.len() >= 2, "the gallery shows its tiles: {tiles:?}"); + assert_eq!(arrows.len(), 3, "up, down and more: {arrows:?}"); + assert!(tiles.windows(2).all(|w| w[0].1.center().x < w[1].1.center().x), "left to right, tiles run rightwards: {tiles:?}"); + assert!( + arrows.iter().all(|a| tiles.iter().all(|(_, r)| a.min.x >= r.max.x)), + "left to right, the arrows are right of every tile: {arrows:?} {tiles:?}" + ); + let ltr_more = (more, arrows.last().copied()); + + // An Arabic interface; its catalog is not needed (the harness has no Arabic font to draw it). + crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n")); + let (tiles, arrows, more) = styles_group_rects(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(tiles.first().map(|(n, _)| n.as_str()), Some("Normal"), "the first tile is Normal"); + assert_eq!(arrows.len(), 3, "up, down and more: {arrows:?}"); + assert!(tiles.windows(2).all(|w| w[0].1.center().x > w[1].1.center().x), "right to left, tiles run leftwards: {tiles:?}"); + assert!( + arrows.iter().all(|a| tiles.iter().all(|(_, r)| a.max.x <= r.min.x)), + "right to left, the arrows are left of every tile: {arrows:?} {tiles:?}" + ); + + // Screen readers find "more", the bottom arrow, by the pane it opens, in either direction. + for (more, bottom) in [ltr_more, (more, arrows.last().copied())] { + assert!(more.is_some() && more == bottom, "the bottom arrow ({bottom:?}) is named for the Styles pane ({more:?})"); + } +} + #[test] fn font_sample_shows_small_caps_and_the_script_mark() { let white = wordcraft_doc::Rgb(255, 255, 255); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 12bfea9749..c921023af8 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does, matched only at the ends of the text so `1/.5` stays a fraction); previously trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (Arabic: the Home tab's Styles gallery reads from the right, its arrows at the left (#5566); readiness unchanged); previously trivial (table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does, matched only at the ends of the text so `1/.5` stays a fraction); previously trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -275,6 +275,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | trivial | Arabic: the Home tab's Styles gallery reads from the right: Normal is the rightmost tile and the scroll arrows and More sit at the gallery's left end, as the rest of the mirrored ribbon (#5566); left to right is unchanged; regression test `the_styles_gallery_reads_from_the_right_in_a_right_to_left_interface` | | 2026-11-04 | trivial | Table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does; `B/.1,250.50` and `B/ 1,250.50` stay text, matching Word 16.0, and the sign is matched only at the ends of the text, so `1/.5` is unchanged | | 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review | | 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review | From 5997aef2f94171ae4764fbb4706904ee53a23865 Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Sun, 8 Nov 2026 03:05:10 +0100 Subject: [PATCH 22/38] ODT: a file's font declarations are kept (gap 19) (#5720) * ODT gap 19: a file's font declarations are kept WordCraft declared each font itself, guessing its kind, in place of the file's declaration. A reader without the font picks its substitute by that, so on a computer without it a document came back in LibreOffice from a save with no edits reflowed, 70 pages instead of 83. A font the file declares stays declared as the file does. Co-Authored-By: Claude Opus 5.5 * ODT: font declarations in single quotes and their name spaces are kept (gap 19) Single-quoted names weren't recognised, so the declaration wasn't replaced and could be added twice. A prefix only the declarations' container declared was left unbound in the saved part. Found by CodeRabbit on this PR. Co-Authored-By: Claude Opus 5.5 * ODT: a font name spelled another way in XML is the same declaration (gap 19) Declarations were matched on their raw text, so O'Brien and O'Brien counted as two fonts and one was declared twice. They are now compared as they read. Co-Authored-By: Claude Opus 5.5 * ODT: a face named other than its family gives that family the file's declaration (gap 19) A face named F1 of the family Mystery: text written from the model names Mystery, and WordCraft's declaration of it kept a guess at the font's kind. It now says what the file's says. Faces are matched in one walk, and namespace prefixes are tracked in a set, as a hostile file with many on its top-level elements could stall import. Co-Authored-By: Claude Opus 5.5 * ODT: font faces are found by parsing and renamed at their own attribute (gap 19) The walk matched text, so a kept script reading like a declaration could be rewritten. It now takes the declarations the parser finds. A rename uses the position of style:name itself, not a search that another attribute with that in its name could mislead. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/formats/src/odt/kept.rs | 50 +++++-- crates/formats/src/odt/package.rs | 53 ++++++++ crates/formats/src/odt/tests_more/kept.rs | 158 ++++++++++++++++++++++ docs/file-format-parity.md | 1 + 5 files changed, 256 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cb78163f4d..c6c2885534 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. - 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. +- 2026-11-05: ODT font declarations are kept (gap 19): replacing them made LibreOffice pick other substitutes for fonts not installed, reflowing a document from 83 pages to 70 on a save with no edits. - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. @@ -843,6 +844,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | | 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | | 2026-11-05 | trivial | Progress log: a watermark or odd and even headers change to a file is saved | +| 2026-11-05 | trivial | Progress log: ODT font declarations kept (gap 19) | | 2026-11-05 | trivial | Progress log: ODT pictures offered in several formats (gap 19) | | 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged | | 2026-10-29 | trivial | Page drawing indexes text-box warp lookups per paragraph (#3614); requested offsets only, bounded prefix traversal, original Linux display and persistence controls; readiness estimates unchanged | diff --git a/crates/formats/src/odt/kept.rs b/crates/formats/src/odt/kept.rs index d5d0f57841..a1aa92ad02 100644 --- a/crates/formats/src/odt/kept.rs +++ b/crates/formats/src/odt/kept.rs @@ -27,6 +27,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc)> { let mut path: Vec = Vec::new(); let mut open: Option<(String, usize, usize)> = None; let mut before_1_3 = false; + let mut prefixes = std::collections::BTreeSet::new(); events_at(xml, |ev, at| match ev { Ev::Start(q, a) => { path.push(q.clone()); @@ -39,11 +40,23 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc)> { if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64) && out.len() < MAX_DEFS { + prefixes.insert(p.to_string()); out.push((format!("ns:{p}"), Arc::from(v.as_str()))); } } return; } + // Those a part's container declares too: what is kept of it leaves the container behind. + if depth == 2 { + for (k, v) in &a { + if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64) + && out.len() < MAX_DEFS + && prefixes.insert(p.to_string()) + { + out.push((format!("ns:{p}"), Arc::from(v.as_str()))); + } + } + } // A reference mark (§6.6.2), which `text:reference-ref` names: WordCraft reads it as a // bookmark, and writes it as both. if content @@ -183,20 +196,41 @@ fn space(q: &str, family: &str) -> String { pub(super) fn defined(xml: &str, def: &str) -> bool { let head = def.split('>').next().unwrap_or(""); let el = head.trim_start_matches('<').split([' ', '/', '\t', '\n', '\r']).next().unwrap_or(""); - let name = ["style:name=\"", "draw:name=\""].into_iter().find_map(|a| { - let at = head.find(a)? + a.len(); - let len = head.get(at..)?.find('"')?; - head.get(at - a.len()..at + len + 1) - }); - let (Some(name), false) = (name, el.is_empty()) else { return false }; + let name = ["style:name", "draw:name"].into_iter().find_map(|a| Some((a, raw_attr(head, a)?))); + let (Some((attr, name)), false) = (name, el.is_empty()) else { return false }; let open = format!("<{el} "); - xml.match_indices(&open).any(|(i, _)| xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| tag.contains(name))) + xml.match_indices(&open).any(|(i, _)| { + xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| raw_attr(tag, attr).is_some_and(|n| attr_text(n) == attr_text(name))) + }) +} + +/// An attribute's value as it reads, however XML spells it (`O'Brien`, `O'Brien`). +pub(super) fn attr_text(raw: &str) -> std::borrow::Cow<'_, str> { + quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw)) +} + +/// Attribute `name`'s value as `tag` (a start tag) writes it, spaced and quoted however XML allows. +pub(super) fn raw_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let past_space = |at: usize| Some(at + tag.get(at..)?.find(|c: char| !c.is_ascii_whitespace())?); + let mut at = tag.find(|c: char| c.is_ascii_whitespace())?; + loop { + let name_at = past_space(at)?; + let eq = name_at + tag.get(name_at..)?.find('=')?; + let value_at = past_space(eq + 1)?; + let rest = tag.get(value_at..)?; + let quote = rest.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let len = rest.get(1..)?.find(quote)?; + if tag.get(name_at..eq)?.trim_end() == name { + return rest.get(1..1 + len); + } + at = value_at + 1 + len + 1; + } } /// The children of `container` (a child of the root) in `xml` (a part WordCraft wrote): each /// one's key in `group` (as [`defs`] gives it) and its markup's byte range; and the range of /// the container's content (`None` when there is no such container). -fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) { +pub(super) fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) { let mut out = Vec::new(); let (mut depth, mut inside) = (0usize, false); let mut open: Option<(String, usize)> = None; diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index 0e3bff03a0..57b81b2567 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -900,6 +900,11 @@ fn with_kept_defs( }; add(&mut content, "", auto); add(&mut styles, "", common); + // A font the file declares is declared as the file does: a reader without the font picks its + // substitute by this, and a different one would reflow every page. + let faces: Vec<&str> = defs.iter().filter(|(k, _)| k.starts_with("font:")).map(|(_, d)| &**d).collect(); + with_file_faces(&mut content, &faces); + with_file_faces(&mut styles, &faces); for (xml, before) in [(&mut content, "').next() else { continue }; + if let Some(name) = raw_attr(head, "style:name") { + named.entry(attr_text(name).into_owned()).or_insert(*face); + } + if let Some(family) = raw_attr(head, "svg:font-family") { + let family = attr_text(family); + let first = family.split(',').next().unwrap_or("").trim().trim_matches(['\'', '"']); + if let Some(font) = model::kept_font(first) { + of_family.entry(font).or_insert(*face); + } + } + } + // WordCraft's own declarations are empty elements. + let (kids, _) = super::kept::children(xml, "office:font-face-decls", "font"); + let mut out = String::with_capacity(xml.len()); + let mut done = 0; + for (_, (from, to)) in kids { + let (Some(before), Some(tag)) = (xml.get(done..from), xml.get(from..to)) else { continue }; + out.push_str(before); + let name = raw_attr(tag, "style:name").map(attr_text); + let same = name.as_deref().and_then(|n| named.get(n)).map(|face| face.to_string()); + let family = || name.as_deref().and_then(|n| of_family.get(n).and_then(|face| face_named(face, n))); + let empty = tag.find('>').is_some_and(|end| end + 1 == tag.len()); + out.push_str(same.or_else(family).filter(|_| empty).as_deref().unwrap_or(tag)); + done = to; + } + out.push_str(xml.get(done..).unwrap_or("")); + *xml = out; +} + +/// `face` (a `style:font-face` element) with the name `name`. +fn face_named(face: &str, name: &str) -> Option { + let head = face.split('>').next()?; + let was = super::kept::raw_attr(head, "style:name")?; + // `was` is a piece of `head`, which starts `face`. + let at = (was.as_ptr() as usize).checked_sub(head.as_ptr() as usize)?; + Some(format!("{}{}{}", face.get(..at)?, x(name), face.get(at + was.len()..)?)) +} + /// An OpenDocument text document's media type, and a template's (ODF 1.3 Part 2 §3.3, Appendix C). pub const TEXT_MIME: &str = "application/vnd.oasis.opendocument.text"; pub const TEMPLATE_MIME: &str = "application/vnd.oasis.opendocument.text-template"; diff --git a/crates/formats/src/odt/tests_more/kept.rs b/crates/formats/src/odt/tests_more/kept.rs index ea1b41a6ec..d00ceac7bc 100644 --- a/crates/formats/src/odt/tests_more/kept.rs +++ b/crates/formats/src/odt/tests_more/kept.rs @@ -159,3 +159,161 @@ fn a_file_a_kept_definition_names_is_written_with_it() { let m = crate::odt::tests::entry(&saved, "META-INF/manifest.xml"); assert!(m.contains(r#"manifest:full-path="Pictures/paper.png""#), "{m}"); } + +#[test] +fn the_files_font_declarations_are_kept() { + // A reader without a font picks its substitute by how the file declares it. WordCraft's own + // guess at a font's kind replaced the file's, and a document came back from a save with no + // edits 13 pages shorter in LibreOffice on a computer without its font. + let ns = NS_T; + let face = r#""#; + let styles = format!( + r#"{face}"# + ); + let content = format!( + r#"{face}Text"# + ); + let check = |saved: &[u8], what: &str| { + for part in [styles_of(saved), content_of(saved)] { + assert!(part.contains(face), "{what}: {part}"); + assert_eq!(part.matches(r#""#); + let styles = format!( + r#"{face}"# + ); + let content = format!( + r#"{face}One"# + ); + let mut d = import(&package(&content, Some(&styles))).unwrap(); + // Text edited, written from the model. + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.text.push('!'); + if let Some(r) = p.runs.last_mut() { + r.len += 1; + } + } + let saved = export(&d).unwrap(); + for part in [styles_of(&saved), content_of(&saved)] { + assert!(part.contains(&face), "{spelled}: the file's: {part}"); + let is_it = |t: &&str| { + let name = t.split('>').next().and_then(|tag| tag.split("style:name=\"").nth(1)).and_then(|v| v.split('"').next()); + name.is_some_and(|n| n == "O'Brien" || n == "O'Brien" || n == "O'Brien") + }; + assert_eq!(part.split(""#; + let styles = format!( + r#"{face}"# + ); + let content = format!( + r#"{face}One"# + ); + let mut d = import(&package(&content, Some(&styles))).unwrap(); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.text.push('!'); + if let Some(r) = p.runs.last_mut() { + r.len += 1; + } + } + let saved = export(&d).unwrap(); + let part = content_of(&saved); + assert!(part.contains(r#"style:font-name="Mystery""#), "{part}"); + let mystery = part.split("").next()); + let mystery = mystery.unwrap_or_else(|| panic!("declared: {part}")); + assert!(mystery.contains(r#"style:font-family-generic="roman""#), "{mystery}"); +} + +/// Only declarations are declarations: not a script's text that reads like one. And a face's name +/// is its own attribute, not another with `style:name` in its name. +#[test] +fn a_face_is_found_and_named_by_its_own_markup() { + let script = + r#"]]>"#; + let face = r#""#; + let styles = format!( + r#"{face}"# + ); + let content = format!( + r#"{script}{face}One"# + ); + let mut d = import(&package(&content, Some(&styles))).unwrap(); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.text.push('!'); + if let Some(r) = p.runs.last_mut() { + r.len += 1; + } + } + let saved = export(&d).unwrap(); + let part = content_of(&saved); + assert!(part.contains(script), "the script as it was: {part}"); + let decls = part.split("").last().and_then(|t| t.split("").next()).unwrap(); + assert!(decls.contains(r#"xstyle:name="F1" style:name="Mystery""#), "{decls}"); + assert!(import(&saved).is_ok()); +} + +/// A font declaration in single quotes is the file's too: it replaces WordCraft's, and isn't +/// declared a second time. +#[test] +fn a_font_declaration_in_single_quotes_is_kept() { + let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#); + let face = r#""#; + let styles = format!( + r#"{face}"# + ); + let content = format!( + r#"{face}x"# + ); + let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap(); + for part in [styles_of(&saved), content_of(&saved)] { + assert!(part.contains(face), "{part}"); + assert_eq!(part.matches(""#; + let styles = format!( + r#"{decls}"# + ); + let content = format!( + r#"{decls}x"# + ); + let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap(); + for part in [styles_of(&saved), content_of(&saved)] { + assert!(part.contains(r#"x:odd="1""#), "{part}"); + assert!(part.contains(r#"xmlns:x="urn:example:x""#), "{part}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 86a3497870..f69e584c4c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -329,6 +329,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. | | 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. | | 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. | +| 2026-11-05 | trivial | ODT: a font the file declares stays declared as the file does. WordCraft's own guess at its kind replaced the declaration, so on a computer without the font LibreOffice picked another substitute: one document came back from a save with no edits at 70 pages instead of 83 In single quotes too, and with the name spaces their container declares. A font's name spelled another way in XML is the same declaration. So does a face named other than its family (`F1`, `Mystery`) for text written from the model. | | 2026-11-05 | trivial | ODT: a frame offering one picture in several formats (LibreOffice's `.svm` metafile, then a PNG) shows the first WordCraft can read; it took the first whatever it was, and the picture was missing on screen and gone once the paragraph was rewritten An object's replacement picture coming before it is kept too. A readable picture after an unreadable replacement is shown, wherever the object comes, and a save from the model keeps both. | | 2026-11-04 | trivial | Engine field parsing and DOCX HYPERLINK targets consume quoted backslash escapes consistently | | 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks | From c38dc17ba9a358ccf25d02d96aa707c622589b5a Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:07:24 +0100 Subject: [PATCH 23/38] Search: the title bar's box takes the typing in place, its results dropping down under it, as in Word (#5398) (#5475) * #5398: the title bar's Search box takes the typing in place, its results dropping down under it Co-Authored-By: Claude Opus 5.5 * #5398: Search in Focus opens the dialog, and Search whose box is gone doesn't keep Escape Review: Focus hides the title bar's Search box, so Alt+Q on the frame Focus comes on opened Search in a box that wasn't drawn, and that hidden Search kept Escape from leaving Focus. Co-Authored-By: Claude Opus 5.5 * #5398: Search takes the keys from its first frame; Enter on a result isn't also the new dialog's OK Review fixes: - The Search box is focused before it reads the frame's input, so keys typed on the frame right after Alt+Q go into it (they were lost). Alt+Q's own text is still swallowed: Alt is read from the shortcut's key event too. - Enter that runs a result is used up, so a dialog the result opens (Word Count) doesn't take the same Enter as its OK and close at once. - The Focus test opens Search with the command Alt+Q runs, so it also runs on macOS, where Option+Q types a character (the test failed on the macOS runner). Co-Authored-By: Claude Opus 5.5 * #5398: Tab moves from the Search box to its results, and Enter runs the focused one Review: the box took the focus back every frame, so Tab couldn't reach a result, and Enter always ran the first hit. The box now takes the focus back unless it is on one of its results (read from last frame's rects, so the page taking it is still undone), and runs the first hit on Enter only while it has the focus; a focused result takes Enter or Space itself. Either way the Enter that ran a result is used up, so the dialog it opens doesn't take it as its OK. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/chrome.rs | 140 +++++++++++++++++- crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/dialogs/dispatch.rs | 16 +- crates/ui-egui/src/dialogs/find.rs | 2 +- crates/ui-egui/src/tests.rs | 195 ++++++++++++++++++++++++- docs/ui-parity.md | 1 + 8 files changed, 355 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c6c2885534..cc76e2d375 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT font declarations are kept (gap 19): replacing them made LibreOffice pick other substitutes for fonts not installed, reflowing a document from 83 pages to 70 on a save with no edits. - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. +- 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -829,6 +830,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | +| 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f496df68a3..de8c151544 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -441,7 +441,11 @@ impl WordApp { return; } let t = theme::Tokens::get(&ctx); - self.escape_free = self.dialog.is_none() && !self.ui.backstage && self.ui.keytips == keytips::Phase::Off && !egui::Popup::is_any_open(&ctx); + self.escape_free = self.dialog.is_none() + && !self.ui.backstage + && self.ui.keytips == keytips::Phase::Off + && !egui::Popup::is_any_open(&ctx) + && !crate::chrome::title_search_open(self, &ctx); // Keytips are read before layout so the frame paints the new state; badges paint after // the ribbon so they sit on top of it. crate::keytips::logic(self, &ctx); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index eb7b0ca425..2676575673 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -446,12 +446,148 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { }); } +/// The title bar's Search box while it is typed in (#5398): the query, whether the text of the +/// shortcut that opened it (Alt+Q's `q` on some platforms) is still to be dropped, and where the +/// results showed last frame. +#[derive(Clone, Default)] +struct TitleSearch { + query: String, + swallow: bool, + results: Option, +} + +/// Width of the Search box, and of its results under it. +const SEARCH_BOX_W: f32 = 260.0; +const SEARCH_RESULTS_W: f32 = 380.0; + +/// Search in the title bar's box, as Word's: when this window drew the box in its last frame +/// (the title bar, or Full Screen's Commands bar), Search opens there, typed into in place with +/// its results under it, instead of in the Search Commands dialog. `true` when it did. +pub(crate) fn open_title_search(app: &mut WordApp) -> bool { + let Some(ctx) = app.ctx.clone() else { return false }; + // Focus hides the box even when the last frame, before Focus came on, drew it. + if Screen::of(&app.session.view) == Screen::Focus || !search_box_drawn(app, &ctx) { + return false; + } + let id = app.window_id("title_search"); + // Only Alt+Q may send its `q` as text after it; a click sends none. Alt is read from the + // shortcut's key event too: the box takes the keys from its first frame, that same text + // included. + let swallow = + ctx.input(|i| i.modifiers.alt || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.alt))); + ctx.data_mut(|d| d.insert_temp(id, TitleSearch { swallow, ..Default::default() })); + ctx.request_repaint(); + true +} + +/// What is typed in the title bar's Search, while it is open. +pub(crate) fn title_search_query(app: &WordApp, ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(app.window_id("title_search"))).map(|s| s.query) +} + +/// Whether this window drew the Search box in its last frame. +fn search_box_drawn(app: &WordApp, ctx: &egui::Context) -> bool { + let shown = ctx.data(|d| d.get_temp::(app.window_id("title_search_shown"))); + shown.is_some_and(|f| f.saturating_add(1) >= ctx.cumulative_frame_nr()) +} + +/// Whether the title bar's Search is being typed in (Escape closes it, not Full Screen). Search +/// whose box is no longer drawn (Focus came on) is dropped, so it can't keep Escape. +pub(crate) fn title_search_open(app: &WordApp, ctx: &egui::Context) -> bool { + if title_search_query(app, ctx).is_none() { + return false; + } + if search_box_drawn(app, ctx) { + return true; + } + ctx.data_mut(|d| d.remove::(app.window_id("title_search"))); + false +} + +/// The Search box being typed in: a text box in its place and the results under it. `false` +/// once it closes: a command or the find ran, Escape, or a click outside both. +fn typing_search(app: &mut WordApp, ui: &mut Ui, control_h: f32, s: &mut TitleSearch) -> bool { + if s.swallow { + ui.ctx().input_mut(|i| i.events.retain(|e| !crate::dialogs::swallow_shortcut_text(&mut s.swallow, e))); + } + let ph = tl!("Search commands and help"); + // The box takes the keys from its first frame: focused before it reads this frame's input, + // so what is typed straight after Alt+Q goes into it. It takes the focus back whenever it lost + // it, except to one of its results (Tab moves there, so a keyboard user can pick one), and + // not while a press on a result is under way (#2986). + let id = app.window_id("title_search_edit"); + let on_result = ui + .memory(|m| m.focused()) + .filter(|f| *f != id) + .and_then(|f| ui.ctx().read_response(f)) + .is_some_and(|r| s.results.is_some_and(|x| x.contains_rect(r.rect))); + if !on_result && !ui.memory(|m| m.has_focus(id)) && !ui.input(|i| i.pointer.any_down()) { + ui.memory_mut(|m| m.request_focus(id)); + } + let typing = ui.memory(|m| m.has_focus(id)); + let edit = ui.add_sized( + vec2(SEARCH_BOX_W, control_h), + egui::TextEdit::singleline(&mut s.query).id(id).hint_text(ph).align(if crate::rtl::on() { + Align2::RIGHT_CENTER + } else { + Align2::LEFT_CENTER + }), + ); + let r = edit.rect; + // Enter in the box runs the first result, and is used up: a dialog that result opens (Word + // Count) mustn't take the same Enter as its OK. A result Tab moved to takes Enter or Space + // itself. + let (enter, escape, pressed_at) = ui.input_mut(|i| { + let pressed = i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten(); + (typing && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), i.key_pressed(egui::Key::Escape), pressed) + }); + let inside = |p: egui::Pos2| r.contains(p) || s.results.is_some_and(|x| x.contains(p)); + if escape || pressed_at.is_some_and(|p| !inside(p)) { + return false; + } + let rtl = crate::rtl::on(); + let (pivot, at) = if rtl { (Align2::RIGHT_TOP, r.right_bottom()) } else { (Align2::LEFT_TOP, r.left_bottom()) }; + let mut done = false; + let shown = egui::Area::new(app.window_id("title_search_results")) + .order(egui::Order::Foreground) + .pivot(pivot) + .fixed_pos(at + vec2(0.0, 2.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(SEARCH_RESULTS_W); + // Rows of a list, as a menu's, not a stack of framed buttons. + ui.style_mut().visuals.button_frame = false; + done = crate::dialogs::search_results(app, ui, &s.query, enter, SEARCH_RESULTS_W); + }); + }); + s.results = Some(shown.response.rect); + if done { + // A result run with Enter from the keyboard: the dialog it opens mustn't take that Enter. + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + !done +} + /// Search ("Tell me"): a box that opens command search. In the title bar, and in the Commands bar -/// of Full Screen (#1678). +/// of Full Screen (#1678). Clicked (or Alt+Q), it takes the typing in place (#5398). fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) { + let frame = ui.ctx().cumulative_frame_nr(); + ui.ctx().data_mut(|d| d.insert_temp(app.window_id("title_search_shown"), frame)); + let id = app.window_id("title_search"); + if let Some(mut s) = ui.ctx().data(|d| d.get_temp::(id)) { + let open = typing_search(app, ui, control_h, &mut s); + ui.ctx().data_mut(|d| { + if open { + d.insert_temp(id, s); + } else { + d.remove::(id); + } + }); + return; + } let t = Tokens::get(ui.ctx()); let rtl = crate::rtl::on(); - let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(SEARCH_BOX_W, control_h), Sense::click()); let ph = tl!("Search commands and help"); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph)); ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8f83ffef89..ab5efa412f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -35,9 +35,11 @@ use bookmarks::bookmark_at_caret; use bookmarks::bookmark_names; pub use chrome::show; pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard}; +pub(crate) use dispatch::search_results; +pub(crate) use find::swallow_shortcut_text; pub use find::{FindExtra, FindTab, GoToWhat}; #[cfg(test)] -use find::{command_matches, goto_page, swallow_shortcut_text}; +use find::{command_matches, goto_page}; #[cfg(test)] use font::{apply_font, preview_size}; use font::{east_asian_char, font_props}; @@ -575,6 +577,10 @@ impl Dialog { } pub fn open(name: &str, app: &mut WordApp) -> Option { + // Search opens in the title bar's box when the window shows it (#5398). + if name == "commands" && crate::chrome::open_title_search(app) { + return None; + } if let Some(d) = crate::dialogs_insert::open(name, app) { return Some(Dialog::Insert(Box::new(d))); } diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index aa1268eb42..141061c27b 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -1067,6 +1067,15 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !r.has_focus() { r.request_focus(); } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + search_results(app, ui, query, enter, 380.0) || ui.input(|i| i.key_pressed(egui::Key::Escape)) +} + +/// Search's results under its box (the Search Commands dialog, or the title bar's drop-down, +/// #5398): suggestions for an empty box, else the matching commands and "Find … in the +/// document". A click, or `enter` (the first command, else the find), does it: `true` when one +/// was done, so the search closes. +pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter: bool, width: f32) -> bool { let reg = app.session.registry.clone(); // An empty box suggests a few common commands, as Word's Search does. let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() { @@ -1076,12 +1085,11 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { reg.all().iter().filter(|c| !NOT_IN_SEARCH.contains(&c.id) && command_matches(c, query)).collect() }; hits.truncate(14); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten(); for c in &hits { let sc = crate::widgets::shortcut_text(app, c.id); let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location)); - if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() { + if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(width, 0.0))).clicked() { run = Some(c.id); } } @@ -1091,14 +1099,14 @@ fn commands_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !text.is_empty() { ui.separator(); let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]); - find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty()); + find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty()); } if let Some(id) = run { let _ = app.run(id, json!({})); } else if find { find_in_navigation_pane(app, ui.ctx(), &text); } - run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape)) + run.is_some() || find } fn paste_special_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index 16185c2172..667f5c8456 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -137,7 +137,7 @@ pub(super) fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, te /// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`): /// a plain key press first means the user is typing, and one swallowed text is all there is. -pub(super) fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool { +pub(crate) fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool { match e { egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => { *swallow = false; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 32136193d2..2ab58a925f 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -4391,10 +4391,9 @@ fn alt_q_opens_search_and_typing_goes_there() { h.event(key(egui::Key::Q, egui::Modifiers::ALT)); h.event(egui::Event::Text("q".into())); h.step(); - let query = |h: &egui_kittest::Harness| match &h.state().dialog { - Some(dialogs::Dialog::Commands { query, .. }) => Some(query.clone()), - _ => None, - }; + // The title bar shows the Search box, so Search opens there, in place (#5398). + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + assert!(h.state().dialog.is_none(), "no Search Commands dialog over the page"); assert_eq!(query(&h).as_deref(), Some(""), "Search is open and empty"); h.step(); { @@ -4408,7 +4407,8 @@ fn alt_q_opens_search_and_typing_goes_there() { h.event(key(egui::Key::Escape, egui::Modifiers::NONE)); h.step(); h.step(); - assert!(h.state().dialog.is_none(), "Escape closes Search"); + assert!(query(&h).is_none(), "Escape closes Search"); + assert!(!h.state().session.view.full_screen_view); // Words that name no command: Enter finds them in the document, in the Navigation pane. h.event(key(egui::Key::Q, egui::Modifiers::ALT)); h.step(); @@ -4420,11 +4420,194 @@ fn alt_q_opens_search_and_typing_goes_there() { h.event(key(egui::Key::Enter, egui::Modifiers::NONE)); h.step(); h.step(); - assert!(h.state().dialog.is_none(), "Enter ran the search"); + assert!(query(&h).is_none(), "Enter ran the search"); assert!(h.state().session.view.nav_pane, "the Navigation pane shows the results"); assert_eq!(h.state().session.find.results.len(), 1); } +/// #5398: a click on the title bar's Search box types into that box, as in Word, with the +/// results dropping down under it, instead of opening a second box in a dialog; a result's click +/// runs it, and a click elsewhere closes Search. +#[test] +fn the_title_bar_search_box_is_typed_in_place() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(6); + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + let open = |h: &mut egui_kittest::Harness| { + h.get_by_label("Search commands and help").click(); + h.run_steps(3); + }; + open(&mut h); + assert!(h.state().dialog.is_none(), "no second box in a dialog"); + assert_eq!(query(&h).as_deref(), Some("")); + assert!(h.ctx.text_edit_focused(), "the box itself takes the typing"); + h.event(egui::Event::Text("word count".into())); + h.run_steps(2); + assert_eq!(query(&h).as_deref(), Some("word count")); + let body = |h: &egui_kittest::Harness| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + assert_eq!(body(&h).trim(), "", "nothing typed into the document"); + // The result is a row of the drop-down (the ribbon's own Word Count button has no "—"). + let row = h + .query_all_by_label_contains("Word Count") + .find(|n| n.accesskit_node().label().is_some_and(|l| l.contains('—'))) + .expect("Word Count among the results"); + row.click(); + h.run_steps(3); + assert!(query(&h).is_none(), "running a result closes Search"); + assert!(h.state().dialog.is_some(), "and Word Count ran (its dialog is open)"); + h.state_mut().dialog = None; + h.run_steps(2); + // A click elsewhere closes it. + open(&mut h); + assert!(query(&h).is_some()); + for pressed in [true, false] { + let at = egui::pos2(720.0, 600.0); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); + assert!(query(&h).is_none(), "a click on the page closes Search"); +} + +/// #5398 (review): Focus hides the Search box, so Search there is the dialog, even on the frame +/// Focus comes on; and Search left open when Focus comes on doesn't keep Escape from leaving it. +#[test] +fn focus_mode_search_never_opens_in_the_hidden_box() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(6); + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + // Search, as Alt+Q opens it (Windows and Linux; macOS has no Alt+Q, Option+Q types a character). + let search = |h: &mut egui_kittest::Harness| { + let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"})); + }; + // The last frame drew the box; Focus comes on just before Search. + h.state_mut().run("view.focus", json!({"value": true})).unwrap(); + search(&mut h); + h.step(); + assert!(query(&h).is_none(), "no Search in a box Focus hides"); + assert!(matches!(h.state().dialog, Some(dialogs::Dialog::Commands { .. })), "Search opens as the dialog"); + h.state_mut().dialog = None; + h.state_mut().run("view.focus", json!({"value": false})).unwrap(); + h.run_steps(3); + // Search open in the title bar, then Focus comes on: Escape leaves Focus. + search(&mut h); + h.run_steps(2); + assert!(query(&h).is_some()); + h.state_mut().run("view.focus", json!({"value": true})).unwrap(); + h.run_steps(3); + assert!(query(&h).is_none(), "Search whose box is gone is dropped"); + h.event(key(egui::Key::Escape, egui::Modifiers::NONE)); + h.run_steps(2); + assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); +} + +/// #5398 (review): what is typed on the very first frame after Search opens goes into the +/// Search box, not into the document; and Enter on a result that opens a dialog (Word Count) +/// leaves that dialog open: the same Enter doesn't also press its OK. +#[test] +fn title_bar_search_takes_the_first_keys_and_enter_opens_the_result() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(6); + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + let body = |h: &egui_kittest::Harness| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + // Search as the ribbon's box and Alt+Q open it, then typing straight away. + let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"})); + h.step(); + h.event(egui::Event::Text("word count".into())); + h.step(); + h.step(); + assert_eq!(body(&h).trim(), "", "nothing typed into the document"); + assert_eq!(query(&h).as_deref(), Some("word count"), "the first keys go into the Search box"); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + h.event(enter); + h.run_steps(4); + assert!(query(&h).is_none(), "Enter ran the first result"); + assert!(matches!(h.state().dialog, Some(dialogs::Dialog::WordCount { .. })), "Word Count is open, not closed by the same Enter"); +} + +/// #5398 (review): Tab moves from the Search box to its results, and the box doesn't take the +/// focus back, so a keyboard user can pick a result; Enter on it runs it. +#[test] +fn tab_moves_to_a_search_result_and_enter_runs_it() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(6); + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let _ = h.state_mut().run("ui.dialog", json!({"name": "commands"})); + h.step(); + h.event(egui::Event::Text("word count".into())); + h.run_steps(2); + let edit = h.state().window_id("title_search_edit"); + assert!(h.ctx.memory(|m| m.has_focus(edit)), "the box has the keys"); + h.event(key(egui::Key::Tab)); + h.run_steps(3); + assert!(!h.ctx.memory(|m| m.has_focus(edit)), "the box didn't take the focus back"); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "a result has it"); + assert_eq!(query(&h).as_deref(), Some("word count"), "Search is still open"); + h.event(key(egui::Key::Enter)); + h.run_steps(4); + assert!(query(&h).is_none(), "Enter ran the result that has the focus"); + assert!(h.state().dialog.is_some(), "its dialog is open, not closed by the same Enter"); +} + +/// #5398 (review): Alt+Q pressed on the page opens Search while the page has the keys; what is +/// typed on the very next frame goes into the Search box, not into the document. +#[cfg(not(target_os = "macos"))] +#[test] +fn keys_typed_right_after_alt_q_go_into_search() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(6); + h.event(egui::Event::Text("Kilns".into())); + h.step(); + let query = |h: &egui_kittest::Harness| crate::chrome::title_search_query(h.state(), &h.ctx); + h.event(egui::Event::Key { key: egui::Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }); + h.step(); + // Typing sends each key's press before its text. + h.event(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::Text("bold".into())); + h.step(); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns", "nothing typed into the document"); + assert_eq!(query(&h).as_deref(), Some("bold"), "the keys went into the Search box"); +} + /// #819: `tools.search` with a query lists matching commands for scripts and agents; every /// word has to match, so "insert table" finds the command that inserts a table. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 03247ffc81..833118b774 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -110,6 +110,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-28 | trivial | Home › Paragraph › Shading: text selected within part of one paragraph is shaded itself, as in Word, so No Color removes text shading imported from a file (#4685) | +| 2026-11-03 | trivial | Search ("Tell me"): a click on the title bar's Search box, or Alt+Q, types into that box itself, as in Word, with the suggestions and results (commands, then Find in the document) dropping down under it; Enter runs the first, Escape or a click elsewhere closes it. It used to open a second box in a Search Commands dialog, which is still what shows where the box isn't drawn (#5398) | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | From eb100edfd6ac480fef11a13f4c5fdc18addd545b Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:09:52 +0100 Subject: [PATCH 24/38] Linux: ignore an AppImage environment inherited from another program (#5653) (#5690) * Linux: ignore an AppImage environment inherited from another program (#5653) An AppImage app that opens a document through xdg-open passes its APPIMAGE, APPDIR, ARGV0 and OWD on to the WordCraft it starts. The self-update trusts APPIMAGE, so an installed WordCraft replaced the other app's file with its own AppImage. The app now starts over without those variables, first thing in main, unless its executable is under APPDIR. The update then goes through the package or plain file this copy really is, and a real AppImage keeps its variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk * #5653 review: turn the self-update off when starting over fails If the process can't start again without another program's AppImage variables, it still carries APPIMAGE, and an accepted update would replace that program's file. The app now turns its self-update off for that process: no update is offered, and Help > Check for Updates says why. The app still opens, so the document the user asked for isn't lost. Also documents the test helpers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk --------- Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 3 + apps/wordcraft/src/appimage_env.rs | 139 ++++++++++++++++++++++++ apps/wordcraft/src/main.rs | 19 ++++ apps/wordcraft/tests/it/appimage_env.rs | 99 +++++++++++++++++ apps/wordcraft/tests/it/main.rs | 2 + crates/ui-egui/src/updates_ui.rs | 45 ++++++++ 6 files changed, 307 insertions(+) create mode 100644 apps/wordcraft/src/appimage_env.rs create mode 100644 apps/wordcraft/tests/it/appimage_env.rs diff --git a/ROADMAP.md b/ROADMAP.md index cc76e2d375..6549c2d8bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged. - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. +- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -814,6 +815,8 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history +> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. +> > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > > 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. diff --git a/apps/wordcraft/src/appimage_env.rs b/apps/wordcraft/src/appimage_env.rs new file mode 100644 index 0000000000..9772244621 --- /dev/null +++ b/apps/wordcraft/src/appimage_env.rs @@ -0,0 +1,139 @@ +//! An AppImage's environment inherited from another program (#5653). +//! +//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where +//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage +//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`, +//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft. +//! +//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables +//! are another program's, and the process starts over without them, before anything reads them. +//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the +//! arguments and the standard streams.) + +use std::ffi::OsStr; +use std::io::Write; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +/// What the AppImage runtime sets; the same list as the documented workaround on the issue. +const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"]; + +/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not: +/// the update then goes through the package or the plain file this copy really is, and at worst +/// says it can't, where the other answer would overwrite a file that isn't WordCraft's. +fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool { + let (Some(exe), Some(appdir)) = (exe, appdir) else { return false }; + let appdir = Path::new(appdir); + if !appdir.is_absolute() { + return false; + } + let Ok(appdir) = appdir.canonicalize() else { return false }; + // No image mounts at the root, where every program on the machine would count as inside it. + appdir.parent().is_some() && exe.starts_with(&appdir) +} + +/// Starts this process over without another program's AppImage variables; returns only when there +/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts. +pub(crate) fn drop_inherited() -> Option { + let appimage = std::env::var_os("APPIMAGE")?; + let exe = std::env::current_exe().ok(); + if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) { + return None; + } + // The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics. + let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage)); + let mut args = std::env::args_os(); + // This file, whatever has happened to its path since it started. + let mut again = Command::new("/proc/self/exe"); + if let Some(arg0) = args.next() { + again.arg0(arg0); + } + again.args(args); + for var in RUNTIME_VARS { + again.env_remove(var); + } + Some(again.exec()) +} + +#[cfg(test)] +mod tests { + use super::runs_from_image; + use std::path::{Path, PathBuf}; + + /// A folder of its own for one test, removed when it ends. + struct Tmp(PathBuf); + + impl Tmp { + /// An empty folder named after `test` and this process, made afresh. + fn new(test: &str) -> Tmp { + let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Tmp(dir) + } + } + + impl Drop for Tmp { + /// Removes the folder and what the test left in it. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A mount with the executable inside it, by real path (`/tmp` may be a link). + fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("usr/bin")).unwrap(); + let dir = dir.canonicalize().unwrap(); + let exe = dir.join("usr/bin/wordcraft"); + (dir, exe) + } + + #[test] + fn an_executable_in_the_mount_is_the_image() { + let tmp = Tmp::new("own"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + assert!(runs_from_image(Some(&exe), Some(dir.as_os_str()))); + } + + #[test] + fn a_link_to_the_mount_is_the_same_mount() { + let tmp = Tmp::new("link"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + let link = tmp.0.join("link"); + std::os::unix::fs::symlink(&dir, &link).unwrap(); + assert!(runs_from_image(Some(&exe), Some(link.as_os_str()))); + } + + #[test] + fn an_installed_executable_is_not_in_another_programs_mount() { + let tmp = Tmp::new("other"); + let (other, _) = mount(&tmp.0, ".mount_other"); + assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str()))); + } + + /// Paths compare by component: one mount's name can start with another's. + #[test] + fn a_mount_whose_name_starts_the_same_is_another_mount() { + let tmp = Tmp::new("prefix"); + let (other, _) = mount(&tmp.0, ".mount_ab"); + let (_, exe) = mount(&tmp.0, ".mount_abc"); + assert!(!runs_from_image(Some(&exe), Some(other.as_os_str()))); + } + + #[test] + fn anything_unclear_is_not_the_image() { + let tmp = Tmp::new("unclear"); + let (dir, exe) = mount(&tmp.0, ".mount_WordCr"); + // No `APPDIR`, one that's gone (the other program has exited) or empty. + assert!(!runs_from_image(Some(&exe), None)); + assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str()))); + assert!(!runs_from_image(Some(&exe), Some("".as_ref()))); + // The root, and a relative path that happens to lead here. + assert!(!runs_from_image(Some(&exe), Some("/".as_ref()))); + assert!(!runs_from_image(Some(&exe), Some(".".as_ref()))); + // No executable path at all. + assert!(!runs_from_image(None, Some(dir.as_os_str()))); + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2c9eb1d679..30cc1082c7 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -10,6 +10,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod animations; +#[cfg(target_os = "linux")] +mod appimage_env; #[cfg(target_os = "macos")] mod apple_events; mod clipboard; @@ -449,9 +451,22 @@ fn main() -> eframe::Result { if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } + // Linux: before anything reads another program's AppImage variables, or a thread starts (#5653). + #[cfg(target_os = "linux")] + let start_over_failed = appimage_env::drop_inherited(); + // Still carrying them, this process must never update itself: the update would replace the + // other program's file. + #[cfg(target_os = "linux")] + let updates_unsafe = start_over_failed.is_some(); + #[cfg(not(target_os = "linux"))] + let updates_unsafe = false; // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); logging::install_panic_hook(); + #[cfg(target_os = "linux")] + if let Some(e) = start_over_failed { + log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}"); + } let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut sample = false; @@ -566,6 +581,10 @@ fn main() -> eframe::Result { let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); app.set_startup_update_check(true); load_prefs(&mut app); + // After the preferences, so the reason it gives is in the interface language. + if updates_unsafe { + app.disable_self_update(); + } load_user_dictionary(&mut app); // Language packs from the suite's font store; an Install click is the person's own. wordcraft_engine::packs::use_font_store(true); diff --git a/apps/wordcraft/tests/it/appimage_env.rs b/apps/wordcraft/tests/it/appimage_env.rs new file mode 100644 index 0000000000..e1de2f0040 --- /dev/null +++ b/apps/wordcraft/tests/it/appimage_env.rs @@ -0,0 +1,99 @@ +//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update +//! would take for this copy's file and replace (#5653). The real program, started both ways. + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const NOTICE: &str = "ignoring the AppImage"; + +/// A folder of its own for one test, removed when it ends. +struct Tmp(PathBuf); + +impl Tmp { + /// An empty folder named after `test` and this process, made afresh. + fn new(test: &str) -> Tmp { + let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Tmp(dir) + } +} + +impl Drop for Tmp { + /// Removes the folder and what the test left in it. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would +/// start it, and waits up to 30 s for it to exit (with no display it can't open a window). +fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .args(args) + .env("APPIMAGE", appimage) + .env("APPDIR", appdir) + .env("ARGV0", "other-app") + .env("OWD", "/") + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .env_remove("WORDCRAFT_CONTROL_PORT") + .env("WORDCRAFT_NO_PREFS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // A start-over that kept the variables would start over for ever, and end here. + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("wordcraft {args:?} was still running after 30 s"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +/// The notice comes once: the program it started over as no longer sees the variables. +#[test] +fn another_programs_variables_are_dropped_once_and_the_program_carries_on() { + let tmp = Tmp::new("dropped"); + let other = tmp.0.join("other-app.AppImage"); + std::fs::write(&other, b"another program").unwrap(); + let out = app(&["--version".into()], &other, &tmp.0); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}"); + assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}"); + assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}"); + assert_eq!(std::fs::read(&other).unwrap(), b"another program"); +} + +/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode. +#[test] +fn starting_over_keeps_the_arguments() { + let tmp = Tmp::new("args"); + let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}"); +} + +/// Its own image's variables stay, or the self-update couldn't find the file to replace. +#[test] +fn its_own_images_variables_stay() { + let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap(); + let mount = exe.parent().unwrap(); + let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}"); + assert!(!stderr.contains(NOTICE), "stderr: {stderr}"); +} diff --git a/apps/wordcraft/tests/it/main.rs b/apps/wordcraft/tests/it/main.rs index 9c26dc905b..cfa99b937d 100644 --- a/apps/wordcraft/tests/it/main.rs +++ b/apps/wordcraft/tests/it/main.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +mod appimage_env; mod control_arg; mod help; #[cfg(unix)] diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index ebd03406f6..a22f9657b4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -51,6 +51,25 @@ impl WordApp { pub fn set_startup_update_check(&mut self, on: bool) { self.updates_rt.startup_check = on; } + + /// Turns the self-update off for the rest of this process: no update is offered, and Help ▸ + /// Check for Updates says why. For a process that still carries another program's AppImage + /// variables, which the update would take for this copy's file and replace (#5653). + #[cfg(not(target_arch = "wasm32"))] + pub fn disable_self_update(&mut self) { + let rt = &mut self.updates_rt; + rt.startup_check = false; + rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check()); + } +} + +/// A check for updates that never finds one and gives the reason as its error, which Help ▸ Check +/// for Updates shows. +#[cfg(not(target_arch = "wasm32"))] +fn refused_check() -> craft_update::updater::CheckSource { + let why = tl!("Updates are off in this window: WordCraft was started with another program's AppImage settings. Start WordCraft from its own menu entry to update it.") + .to_string(); + std::sync::Arc::new(move || Err(why.clone())) } pub(crate) fn check(app: &mut WordApp) { @@ -134,3 +153,29 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use craft_update::updater::Status; + + /// With the self-update off, a startup check is never made, and Help ▸ Check for Updates (the + /// command's own path) ends in the reason instead of a release to install (#5653). + #[test] + fn with_the_self_update_off_a_check_finds_nothing_and_says_why() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.set_startup_update_check(true); + app.disable_self_update(); + assert!(!app.updates_rt.startup_check, "no check at start"); + let ctx = egui::Context::default(); + super::check(&mut app); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !matches!(app.updates_rt.updater.status(), Status::Failed(_)) { + assert!(std::time::Instant::now() < deadline, "the check never ended: {:?}", app.updates_rt.updater.status()); + super::tick(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let status = app.updates_rt.updater.status(); + assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}"); + assert!(!app.updates_rt.updater.prompt_open(), "no update offered"); + } +} From 9b6e9564a2d276a0a7898c75580bf6708d0a21d8 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:20:49 +0100 Subject: [PATCH 25/38] Lists: a numFmt none level shows its literal text, so RESPONSE: labels the paragraph (#4985) (#5055) * #4985: a numFmt none list level shows its literal text Word labels a paragraph whose level has numFmt none and lvlText RESPONSE: with RESPONSE:. Counters::next_label returned no label for any none level; now only a none level of placeholders shows nothing. Its own %n stays empty, and it still doesn't count. Numbering::shows_label agrees. Co-Authored-By: Claude Opus 5.5 * #4985: rustfmt Co-Authored-By: Claude Opus 5.5 * #4985: a "none" list level counts, and only its own number is empty Taken from MeHail-02's numbering-literal-none branch (46405e66), checked in Word for Windows: a none level is an ordinary level whose own number reads as nothing. It counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent unless it already ends there (the Chapter 1 list's Heading 2 stays at the margin). Co-Authored-By: MeHail Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF in full context keeps a none-level heading's parent Review: a none level now has a label, so its headings reach the style index, which leaves the none level out of the levels it keeps; \w and \r took the last kept level for the heading's own and dropped the parent. Levels now carry their index, and the levels above are picked by it. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF number switches on a none-level heading Review: in full context an empty label of its own added a trailing period (3.), and the suppress switch gave a none level's label (Note), which holds no number, back whole. An empty own part is left out, and a none level's label with no number is suppressed to nothing. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF \s and \t on none-level labels Review: \s took a none level's label (Note) for a chapter number; the heading's count stands in, as for an unnumbered heading. \t matched any ancestor's number inside a word (the a of Chapter); only the numbers the label's template shows count now. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF takes a label's numbers by their spans Review: \s took the last word of a label (Response of "3 Response") and fell back on the heading count for a none level showing its parent's number (%1); \t kept text attached to a number (Section3). A label's numbers are now the spans of the values its template shows, with their delimiters (a letter number only standing apart from other letters); \s takes the last of them. Co-Authored-By: Claude Opus 5.5 * #4985: tests edit the numbering through Arc::make_mut after merging main Main shares the numbering between undo steps (#2694), so `Document::numbering` is an `Arc`; the tests this PR adds wrote to it directly and no longer built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: MeHail Co-authored-by: Martin Ball --- ROADMAP.md | 7 ++- crates/doc/src/numbering.rs | 43 +++++++++++--- crates/docx/tests/it/fixtures.rs | 25 ++++++++ crates/engine/src/cmd/lists.rs | 3 +- crates/layout/src/para/lines.rs | 3 + crates/layout/src/styleref.rs | 91 +++++++++++++++++++++++------ crates/layout/src/tests.rs | 24 ++++++++ crates/layout/src/tests/styleref.rs | 43 ++++++++++++++ docs/gaps.md | 4 +- docs/layout-parity.md | 3 +- 10 files changed, 216 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6549c2d8bf..2418e28c8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). +- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged. +- 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-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -834,6 +838,8 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | +| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged | +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | @@ -883,7 +889,6 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) | | 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | -| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 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 | | 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) | diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index a5d2ec6e10..5ab94ea877 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -228,7 +228,7 @@ impl Numbering { /// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives /// one), without counting it. pub fn shows_label(&self, num: u32, level: u8) -> bool { - self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None) + self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some() } pub fn level(&self, num: u32, level: u8) -> Option<&Level> { if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) { @@ -325,12 +325,9 @@ impl Counters { let lv = (level as usize).min(8); // This list's own definition of a level wins over its abstract list's. let level_def = |k: usize| numbering.level(num, k as u8); + // A "none" level (Word's numFmt none) still counts and restarts the levels below it; only + // its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985). let def = level_def(lv)?.clone(); - // A "none" level shows no label at all (Word's numFmt none) — not even its text. - if def.format == NumFormat::None { - self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() }); - return None; - } let st = self.state.entry(abs.id).or_insert([0; 9]); let fl = self.flags.entry(abs.id).or_default(); // Only this level's override, not the num's others: the counters are shared by every @@ -382,7 +379,14 @@ impl Counters { let started = *fl; let numeric = def.format != NumFormat::Bullet; let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() }; - self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }); + // A "none" level shows its literal text, but has no number of its own for a reference to + // show (#4985): it is kept as one that shows none. + let counted = if def.format == NumFormat::None { + CountedLabel { label: None, levels: Vec::new() } + } else { + CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) } + }; + self.labels.insert((abs.id, lv as u8), counted); Some((label, def)) } /// Which levels of `num`'s list have counted a paragraph yet (a level never used has no @@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s let counted = started.get(k).is_some_and(|f| f.started); let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) }; // Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own - // format (二 of a Chinese outline becomes 2 in "2.1"). + // format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty. let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) { - NumFormat::DecimalZero => NumFormat::DecimalZero, + f @ (NumFormat::DecimalZero | NumFormat::None) => f, _ if def.legal => NumFormat::Decimal, f => f, }; @@ -556,6 +560,27 @@ mod tests { assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero)); } + #[test] + fn a_none_level_shows_its_text_and_still_counts() { + // As Word shows them (#4985): a "none" level's literal text is its label, its own number + // reads as nothing (also in legal numbering), and it restarts the levels below it. + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() }; + let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") }; + if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) { + num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)]; + } + assert!(n.shows_label(id, 0)); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + } + #[test] fn a_start_override_restarts_only_its_own_level_of_a_shared_list() { // One abstract list numbers the headings through two nums: one for levels 1-4, and one diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 1df2e96f9a..3b4779a248 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -2462,6 +2462,31 @@ fn east_asian_number_formats_read_label_and_round_trip() { assert_eq!(formats(&back), want); } +/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that +/// text, as in Word, and keeps both through a save. +#[test] +fn a_none_level_s_literal_text_is_its_label() { + use wordcraft_doc::numbering::Counters; + use wordcraft_doc::section::NumFormat; + let numbering = format!( + r#""# + ); + let body = r#"X"#; + let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]); + let label = |d: &Document| { + let n = paras(d).first().and_then(|p| p.props.numbering)?; + Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0) + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(label(&d).as_deref(), Some("RESPONSE:")); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!( + back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)), + Some((NumFormat::None, "RESPONSE:", 72.0, 72.0)) + ); + assert_eq!(label(&back).as_deref(), Some("RESPONSE:")); +} + /// #464: a bookmark whose end follows the document's final table keeps its end (it goes into /// the table's last cell) and is saved as a matched pair. #[test] diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 5fa185324c..987e9fd537 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -489,7 +489,8 @@ mod tests { ("headings", [Some("1"), Some("1.1"), Some("1.1.1")]), ("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]), ("outlineHeadings", [Some("I."), Some("A."), Some("1.")]), - ("chapter", [Some("Chapter 1"), None, None]), + // The lower levels are "none" levels with no text: an empty label, as in Word (#4985). + ("chapter", [Some("Chapter 1"), Some(""), Some("")]), ] { let mut s = headings("Heading3"); s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap(); diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index 9edfeb2b7f..f29a7029f9 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -297,6 +297,9 @@ impl<'e> Breaker<'e> { }; let end = lab.x + lab.width; match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) { + // An empty label (a "none" level with no text) that ends right at the left indent takes + // no tab: the text starts there, as in Word (#4985), and not at the next stop. + LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end, LevelSuffix::Tab => { let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos; // The level's own tab stop after the number, when it comes first. diff --git a/crates/layout/src/styleref.rs b/crates/layout/src/styleref.rs index 5f2db6045e..8d03623e6f 100644 --- a/crates/layout/src/styleref.rs +++ b/crates/layout/src/styleref.rs @@ -125,9 +125,11 @@ struct Entry { runs: Vec<(String, String)>, /// The list label as shown (`Chapter 2.`). label: Option, - /// Each level's value up to the paragraph's, formatted; and whether the label's own - /// template shows that level. - levels: Vec<(String, bool)>, + /// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level): + /// its index, its value formatted, and whether the label's own template shows it. + levels: Vec<(u8, String, bool)>, + /// The paragraph's own list level. + level: u8, /// How many paragraphs of its style there are up to this one. ordinal: u32, } @@ -180,7 +182,7 @@ impl StyleIndex { *ord = ord.saturating_add(1); let ordinal = *ord; let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0); - let (mut label, mut levels) = (None, Vec::new()); + let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8); if let Some(n) = num && let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level) && def.format != NumFormat::Bullet @@ -193,9 +195,10 @@ impl StyleIndex { } let v = if *v == 0 { lv.start } else { *v }; let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format }; - levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); + levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); } label = Some(l); + level = n.level.min(8); } let mut runs: Vec<(String, String)> = Vec::new(); let mut off = 0usize; @@ -221,7 +224,7 @@ impl StyleIndex { list.push(i); } } - entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal }); + entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal }); } StyleIndex { entries, by_path, styles, para_entries, char_entries } } @@ -323,18 +326,32 @@ impl StyleIndex { let mut out = match code.number { Number::Text if code.position => String::new(), Number::Text => text, - Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()), + // The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`); + // a label of the paragraph's own number that shows none recognisably, its last word; + // else, as for an unnumbered heading or a "none" level's `Note`, the heading's count. + Number::Chapter => { + let own = e.levels.iter().any(|(k, ..)| *k == e.level); + e.label + .as_deref() + .and_then(|l| { + let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels)); + shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l))) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| e.ordinal.to_string()) + } Number::Plain | Number::Full => { let own = match &e.label { - Some(l) if code.suppress => suppress(l, &e.levels), + Some(l) if code.suppress => suppress(l, &e.levels, e.level), Some(l) => l.trim().trim_end_matches('.').to_string(), None => e.ordinal.to_string(), }; if code.number == Number::Full && e.label.is_some() { - // Levels above that the label's own template doesn't show. - let above: Vec<&str> = - e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect(); - if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) } + // Levels above the paragraph's that the label's own template doesn't show. By + // level, not position: a "none" level has no entry, its own (#4985) included. + let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect(); + // An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`. + above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::>().join(".") } else { own } @@ -356,13 +373,46 @@ fn chapter_number(label: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()).to_string() } -/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). -fn suppress(label: &str, levels: &[(String, bool)]) -> String { - let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect(); - let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") }; +/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's +/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985); +/// any other label that holds none is kept whole. +fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String { + // Each word's numbers, without the text attached to them (`Section3` → `3`). + let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect(); + let none = !levels.iter().any(|(k, ..)| *k == level); + let s = match kept.is_empty() { + true if none => String::new(), + true => label.trim().to_string(), + false => kept.join(" "), + }; s.trim_end_matches('.').to_string() } +/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown), +/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` → +/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing +/// apart from other letters: the `a` of `Chapter` isn't one. +fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> { + let alpha = |c: Option| c.is_some_and(char::is_alphabetic); + let mut span: Option<(usize, usize)> = None; + for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) { + let letters = v.chars().any(char::is_alphabetic); + for (i, _) in word.match_indices(v.as_str()) { + let end = i.saturating_add(v.len()); + let before = word.get(..i).and_then(|s| s.chars().next_back()); + let after = word.get(end..).and_then(|s| s.chars().next()); + if letters && (alpha(before) || alpha(after)) { + continue; + } + span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end)))); + } + } + let (a, b) = span?; + let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + word.get(a.saturating_sub(lead)..b.saturating_add(trail)) +} + /// Does `p` hold a STYLEREF field? pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool { p.objects.iter().any(is_styleref) @@ -441,7 +491,14 @@ mod tests { #[test] fn suppress_keeps_the_number() { - assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2"); + assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2"); + assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show"); + assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number"); + assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a"); + assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay"); + assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2"); + assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label"); + assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number"); assert_eq!(chapter_number("Chapter IV."), "IV"); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ca0805afc7..fa69a3d93a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1820,6 +1820,30 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() { + // #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs + // the text to a hanging indent, as a numbered item's. An empty label already at the left indent + // takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5"). + use wordcraft_doc::section::NumFormat; + for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] { + let mut d = Document::from_text("X"); + let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); + for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::None; + l.text = text.into(); + (l.indent, l.hanging) = (indent, hanging); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + assert_eq!(labels(&l), [text]); + let x = l.caret(&Pos::body(0, 0)).unwrap().x; + assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}"); + } +} + #[test] fn break_only_list_has_no_label() { use wordcraft_doc::ListKind; diff --git a/crates/layout/src/tests/styleref.rs b/crates/layout/src/tests/styleref.rs index e800f51f87..b6e91f6017 100644 --- a/crates/layout/src/tests/styleref.rs +++ b/crates/layout/src/tests/styleref.rs @@ -97,6 +97,49 @@ fn character_styles_and_paragraph_numbers() { assert_eq!(h[3], ["two", "3", "3.a", "3"]); } +/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's +/// number in full context (`\w`): the parent is not taken for the heading's own level, which +/// has no number to list. +#[test] +fn full_context_keeps_the_parent_of_a_none_level_heading() { + let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() }; + n.level_overrides = vec![(1, note)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]); +} + +/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an +/// empty label of its own adds no part in full context: `3`, not `3.`. +#[test] +fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() { + let numbered = |text: &str, codes: &[&str]| { + let mut d = doc(CHAPTERS, codes); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() }; + n.level_overrides = vec![(1, level)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + headers(&d)[3].clone() + }; + assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn"); + // `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a + // label showing its parent's number (`%1`, `%1 Response`) gives that number. + assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]); + assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]); + assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]); + assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]); + assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]); +} + /// Editing a heading updates the headers that show it, through the paragraph cache. #[test] fn headers_follow_heading_edits() { diff --git a/docs/gaps.md b/docs/gaps.md index 173a8d8db9..66f41b99e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -189,6 +189,9 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | | 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed | | 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | +| 2026-10-31 | trivial | A list level with numFmt none shows its literal text as its label and still counts, as in Word (#4985); no ranked gap changed; readiness estimates unchanged | +| 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-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | @@ -223,7 +226,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | | 2026-11-01 | minor | #8: Language packs (CP4.3): install and remove a language's spelling and hyphenation from File › Options › Language, the missing-pack bar, About › Language data, `proof.packs`/`proof.installPack`/`proof.removePack`; Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`) | | 2026-11-01 | trivial | Gap 8's evidence names craft-proof (storytold/craft-libs `crates/proof`) and craft-i18n `language-data/` instead of WordCraft's removed `crates/proof` (#5202). No ranked gap changed. | -| 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 | | 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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b3bb97b603..a1125c2619 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -58,7 +58,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text, and a kept-together paragraph low in a continuous section is measured against the next page's body (its header, footer and carried notes), not the current one's (#3907). | 1–2 | | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels after a leading page break | ✅ | `layout::tests::bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | -| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | +| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). A level with number format `none` is an ordinary level whose own number reads as nothing, as in Word for Windows (Microsoft 365, checked by MeHail-02): its label is its text with that number empty (`RESPONSE:`; `Part` above `%1.%2` gives `.1`), it counts and restarts the levels below it, it stays empty in legal numbering, and an empty label still tabs the text to the indent unless it already ends there (Heading 2 of the Chapter 1 list stays at the margin) (#4985; `doc::numbering::tests::a_none_level_shows_its_text_and_still_counts`, `layout::tests::a_none_format_label_shows_its_text_and_tabs_to_the_indent`). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | @@ -299,6 +299,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | +| 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From 852e67d587c0727b818304af9014f19cf184a284 Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:25:17 +0100 Subject: [PATCH 26/38] A template opened from the file manager starts a new document (#3433) (#5698) A Word template (.dotx, .dotm, .dot) the platform hands the app, from a double-click in the file manager, the command line or the Finder, opened as itself: the window called it saved, AutoSave was on, and Ctrl+S wrote over the template. Word's default verb for a template makes a new, untitled document based on it. WordApp::open_from_platform sends a Word template to file.newFromTemplate and opens anything else as File > Open does; main.rs (also for a name that isn't valid Unicode) and the Apple Events handler use it. file.newFromTemplate reads the template as file.open reads a file (password, data, an empty file) and, with replaceBlank, takes an untouched blank document's place, so a launch shows one window. The Password box retries the command that asked for it, so a protected template still starts a new document. File > Open and drops still open a template itself. Claude-Session: https://claude.ai/code/session_01LvG6E1XTfHtZDbfYwfREhR Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + apps/wordcraft/src/apple_events.rs | 5 +- apps/wordcraft/src/main.rs | 10 +-- crates/engine/src/cmd/file.rs | 70 ++++++++++++++------- crates/engine/src/cmd/tools/specs.rs | 16 ++--- crates/engine/src/cmd/user_templates.rs | 67 ++++++++++++++++++++ crates/ui-egui/src/app/commands.rs | 6 +- crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/dialogs/dispatch.rs | 7 ++- crates/ui-egui/src/documents.rs | 31 ++++++++++ crates/ui-egui/src/tests.rs | 82 +++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 12 files changed, 261 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2418e28c8f..388ddcc08c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -169,6 +169,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. +- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -820,6 +821,7 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history > 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. +> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e79d923a9a..56a284c0cb 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -36,14 +36,15 @@ impl AppleEvents { /// Open the documents and act on the quit requests that arrived since the last frame. A document /// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it -/// is already open, by showing it. Quitting closes the window, which asks about every document +/// is already open, by showing it; a Word template starts a new document based on it, as Word's +/// default verb for one does (#3433). Quitting closes the window, which asks about every document /// with unsaved changes. pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { for e in inbox.drain() { match e { Event::Open(paths) => { for p in paths.iter().filter_map(|p| p.to_str()) { - match app.open_file(p, None) { + match app.open_from_platform(p, None) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 30cc1082c7..1c29e22899 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -423,14 +423,15 @@ fn without_launch_args(args: Vec) -> Vec { /// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it /// gets the file's bytes under the lossy name, and the document then keeps the real path to save to. -/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. +/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word +/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path. fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { let name = path.to_string_lossy(); let opened = wordcraft_engine::io::read_document(path) - .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))); + .and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes)))); match opened { // Not when it waits for a password: that open names the document itself. - Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()), + Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()), Ok(_) => {} Err(e) => { app.report_open_failure(&name, &e); @@ -624,8 +625,9 @@ fn main() -> eframe::Result { if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() { app.start_screen(); } + // A Word template named here starts a new document based on it (#3433). for f in files { - if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { + if let Err(e) = app.open_from_platform(&f, None) { app.report_open_failure(&f, &e); } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 67284a2474..c9d523c5d6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ); } let password = p::str(v, "password"); - let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { - let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; - // An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new - // blank document that saves back to it (#1384). - if bytes.is_empty() { - (normal_document(s), false) - } else { - crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? - } - } else if crate::io::is_empty_file(std::path::Path::new(path)) { - (normal_document(s), false) - } else { - crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? - }; - // Plain text and Markdown carry no language: they are in the editing language. - let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); - if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) - && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") - { - Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone()); - } + // An empty file opens as a new blank document that saves back to it (#1384). + let (doc, encrypted) = read_document(s, path, v)?; match s.used_because() { None => s.set_document(doc), Some(why) => { @@ -294,6 +275,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ) } +/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only +/// naming it), read with `password`, and whether it was encrypted: what `file.open` and +/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes +/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are +/// in the editing language. +fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> { + let password = p::str(v, "password"); + let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { + let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; + if bytes.is_empty() { + (normal_document(s), false) + } else { + crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? + } + } else if crate::io::is_empty_file(std::path::Path::new(path)) { + (normal_document(s), false) + } else { + crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? + }; + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); + if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) + && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") + { + Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone()); + } + Ok((doc, encrypted)) +} + +/// File › New from a template (#2852): a new, untitled document with the template's content, +/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`). +/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank +/// document gives way to it, as to a file `file.open` opens: that is how a template the platform +/// hands the app starts (#3433). The template is only read; the new document has no file, so +/// saving it asks where to write, and a protected template's password doesn't come along. +pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult { + let path = p::req_str(v, "path")?; + let (mut doc, _) = read_document(s, path, v)?; + doc.settings.attached_template = Some(path.to_string()); + if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() { + s.set_document(doc); + } else { + s.add_document(doc); + } + s.dirty = true; + sel_result(s) +} + /// Close a document with no window to ask about it (agents, headless sessions). fn close_document(s: &mut Session, v: &Value) -> CmdResult { let id = match v.get("id").filter(|x| !x.is_null()) { diff --git a/crates/engine/src/cmd/tools/specs.rs b/crates/engine/src/cmd/tools/specs.rs index 636faf15b2..f9a9684d5f 100644 --- a/crates/engine/src/cmd/tools/specs.rs +++ b/crates/engine/src/cmd/tools/specs.rs @@ -121,17 +121,11 @@ pub fn specs() -> Vec { CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v)) .params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#) .pure(), - CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| { - let path = p::req_str(v, "path")?; - let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; - // As in Word, the new document is attached to the template it came from (#2852). - doc.settings.attached_template = Some(path.to_string()); - s.add_document(doc); - s.dirty = true; - sel_result(s) - }) - .params(r#"{"path": string} → a new, untitled document from the template, attached to it"#) - .pure(), + CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", crate::cmd::file::new_from_template) + .params( + r#"{"path": string, "data"?: base64 (the template's bytes; `path` then only names it), "password"?: string (for a password-protected template), "replaceBlank"?: bool (an untouched blank document gives way to it, as to a file file.open opens)} → a new, untitled document from the template, attached to it"#, + ) + .pure(), CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| { let path = p::req_str(v, "path")?; // A .dotm keeps its name (#2852); anything else becomes a .dotx. diff --git a/crates/engine/src/cmd/user_templates.rs b/crates/engine/src/cmd/user_templates.rs index 9180a8fe34..ff7b5bca87 100644 --- a/crates/engine/src/cmd/user_templates.rs +++ b/crates/engine/src/cmd/user_templates.rs @@ -337,6 +337,73 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// #3433: a template the platform hands the app takes an untouched blank document's place + /// (`replaceBlank`), as a file `file.open` opens does; otherwise, and without the flag, the + /// new document opens beside the others, as File › New's does. Either way the template on + /// disk is only read: the new document has no path, so a save can't write over it. + #[test] + fn new_from_template_replaces_an_untouched_blank_document_only_when_asked() { + let d = scratch("replace-blank"); + let path = d.join("Letterhead.dotx"); + template(&path, "Letterhead text", ""); + let bytes = std::fs::read(&path).unwrap(); + let from = json!({"path": path.to_string_lossy(), "replaceBlank": true}); + + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &from).unwrap(); + assert_eq!(s.documents().len(), 1, "the untouched blank document gave way"); + assert_eq!(s.path, None); + assert!(s.dirty); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Letterhead text")); + let saved = s.run("file.save", &json!({})).unwrap(); + assert_eq!(saved["saved"], json!(false), "no file to save to: Save As asks where"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is never written over"); + + // The new document has unsaved changes, so a second one opens beside it. + s.run("file.newFromTemplate", &from).unwrap(); + assert_eq!(s.documents().len(), 2); + // Without the flag, even an untouched blank document stays (File › New). + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.documents().len(), 2); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&d); + } + + /// #3433: a protected template asks for its password like `file.open`, and the document made + /// from it isn't protected; a template's bytes can come in `data` (a file name that isn't + /// valid Unicode, #1440), its path then only naming it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn new_from_template_reads_a_protected_template_and_bytes_like_file_open() { + let d = scratch("protected"); + let path = d.join("Locked.dotx"); + let mut t = Session::new(wordcraft_doc::Document::from_text("Locked template")); + t.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap(); + let name = path.to_string_lossy(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + let e = s.run("file.newFromTemplate", &json!({"path": name})).unwrap_err().to_string(); + assert!(crate::io::needs_password(&e), "{e}"); + let e = s.run("file.newFromTemplate", &json!({"path": name, "password": "wrong"})).unwrap_err().to_string(); + assert!(crate::io::wrong_password(&e), "{e}"); + assert_eq!(s.documents().len(), 1, "nothing opened yet"); + s.run("file.newFromTemplate", &json!({"path": name, "password": "sesame", "replaceBlank": true})).unwrap(); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Locked template")); + assert_eq!((s.path.as_ref(), s.password.is_some()), (None, false)); + + let plain = d.join("Plain.dotx"); + template(&plain, "From bytes", ""); + let data = crate::cmd::insert::base64_encode(&std::fs::read(&plain).unwrap()); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &json!({"path": "Pl\u{FFFD}in.dotx", "data": data, "replaceBlank": true})).unwrap(); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("From bytes")); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Pl\u{FFFD}in.dotx")); + assert_eq!(s.path, None); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn the_templates_folder_option_is_saved_and_checked() { let d = scratch("option"); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 349cd8cc3a..6d3c005642 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -318,7 +318,8 @@ impl WordApp { file_dialogs::Asked::Busy => Ok(json!({"saved": false})), }; } - let retry = (id == "file.open").then(|| params.clone()); + // Opening a file, or a new document from a template, reads a file that may be protected. + let retry = matches!(id, "file.open" | "file.newFromTemplate").then(|| params.clone()); let retry_path = retry.clone(); let r = self.execute(id, params); if let (Err(e), Some(params)) = (&r, retry) @@ -326,7 +327,8 @@ impl WordApp { { let path = params.get("path").and_then(Value::as_str).unwrap_or(""); let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() }); + self.dialog = + Some(dialogs::Dialog::Password { name, then: id.to_string(), params, password: Default::default(), message: String::new() }); return Ok(json!({"pending": "password"})); } // Whoever asked, a failed open names the file and the cause (#1103). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab5efa412f..0caadcbaed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -416,10 +416,12 @@ pub enum Dialog { #[serde(skip)] basis: Box, }, - /// Opening a password-protected document (#55): the password, then `file.open` with - /// `params` again. The password is never serialised or printed. + /// Opening a password-protected document (#55): the password, then the command that asked + /// for it (`then`: `file.open`, or `file.newFromTemplate` for a protected template, #3433) + /// with `params` again. The password is never serialised or printed. Password { name: String, + then: String, #[serde(skip)] params: Value, #[serde(skip)] diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 141061c27b..c2a07d60e8 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -1290,7 +1290,7 @@ fn insert_merge_field_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> } fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let Dialog::Password { name, params, password, message } = d else { + let Dialog::Password { name, then, params, password, message } = d else { return false; }; ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)])); @@ -1311,8 +1311,9 @@ fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if let Some(o) = p.as_object_mut() { o.insert("password".into(), json!(password.as_str())); } - // `execute`, not `run`: Save Changes was asked when the open began. - match app.execute("file.open", p) { + // `execute`, not `run`: Save Changes was asked when the open began. The command that + // asked runs again, so a protected template still starts a new document (#3433). + match app.execute(then, p) { Ok(_) => { app.ui.backstage = false; return true; diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index e3fc93553a..61fe3e474e 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -130,6 +130,28 @@ impl WordApp { self.run("file.open", json!({"path": path})) } + /// Open a file the platform hands the app: one named on the command line (a double-click in + /// the file manager, the shell's Open verb) or sent by the Finder. A Word template (`.dotx`, + /// `.dotm`, `.dot`) starts a new, untitled document based on it, as Word's default verb for a + /// template does (#3433): saving it asks where to write, and the template is never written + /// over. File › Open still opens a template itself, to edit it, as in Word. Anything else + /// opens as [`WordApp::open_file`] opens it. `data` holds the file's bytes (base64) when its + /// name can't be passed as text (#1440); `path` then only names it. + pub fn open_from_platform(&mut self, path: &str, data: Option<&str>) -> Result { + let mut params = json!({"path": path}); + if let Some(data) = data { + params["data"] = json!(data); + } + if is_word_template(path) { + params["replaceBlank"] = json!(true); + return self.run("file.newFromTemplate", params); + } + match data { + Some(_) => self.run("file.open", params), + None => self.open_file(path, None), + } + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; @@ -197,6 +219,15 @@ impl WordApp { } } +/// Is `path` a Word template by its extension (`.dotx`, `.dotm`, `.dot`, in any case)? These are +/// the template types WordCraft registers with the platform. +pub fn is_word_template(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|e| ["dotx", "dotm", "dot"].iter().any(|t| e.eq_ignore_ascii_case(t))) +} + /// The name an open document goes by in the window: the engine's title, with an untitled /// document's `Document2` in the interface language. pub fn display_title(d: &DocInfo) -> String { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 2ab58a925f..7e24875be4 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2917,6 +2917,88 @@ fn a_command_line_file_still_asks_for_its_password_after_the_startup_document_is assert!(body_text(&a).contains("Locked away"), "{}", body_text(&a)); } +/// A template WordCraft can be handed, saved by the engine with `text` (and `password`). +fn template_file(path: &std::path::Path, text: &str, password: Option<&str>) -> Vec { + let mut s = Session::new(wordcraft_doc::Document::from_text(text)); + s.run("file.save", &json!({"path": path.to_string_lossy(), "password": password})).unwrap(); + std::fs::read(path).unwrap() +} + +/// #3433: a Word template the platform hands the app (the command line, a double-click in the +/// file manager, the Finder) starts a new, untitled document based on it, in place of the +/// untouched blank document, as Word does; saving can't write over the template. A document is +/// still opened as itself, and File › Open still opens a template itself, to edit it. +#[test] +fn a_template_the_platform_hands_the_app_starts_a_new_document() { + let dir = scratch("platform-template"); + let path = dir.join("Letterhead.DOTX"); + let bytes = template_file(&path, "Letterhead text", None); + let name = path.to_string_lossy().to_string(); + + let mut a = app(); + let r = a.open_from_platform(&name, None).unwrap(); + assert_eq!(r.get("pending"), None, "{r}"); + assert_eq!(a.session.documents().len(), 1, "the blank startup document gave way"); + assert_eq!(a.session.path, None); + assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(name.as_str())); + assert!(body_text(&a).contains("Letterhead text")); + assert_eq!(a.title_stem(), "Document1"); + a.run("text.insert", json!({"text": "Dear reader, "})).unwrap(); + let _ = a.run("file.save", json!({})); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "Save never writes over the template"); + assert_eq!(a.session.path, None); + + // A document opens as itself, beside the new one. + let doc = dir.join("Report.docx"); + template_file(&doc, "Report text", None); + a.open_from_platform(&doc.to_string_lossy(), None).unwrap(); + assert_eq!(a.session.path.as_deref(), Some(doc.as_path())); + // File › Open edits the template itself. + let mut b = app(); + b.open_file(&name, None).unwrap(); + assert_eq!(b.session.path.as_deref(), Some(path.as_path())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #3433: a protected template from the platform asks for its password, and the password box +/// retries the command that asked, so the template still starts a new document instead of +/// opening itself. +#[test] +fn a_protected_template_from_the_platform_asks_for_its_password_and_stays_a_new_document() { + let dir = scratch("platform-template-password"); + let path = dir.join("Locked.dotm"); + let bytes = template_file(&path, "Locked template", Some("sesame")); + let name = path.to_string_lossy().to_string(); + + let mut a = app(); + a.open_from_platform(&name, None).unwrap(); + assert_eq!(prompt(&a), Some("password")); + let (then, params) = match &a.dialog { + Some(crate::dialogs::Dialog::Password { then, params, .. }) => (then.clone(), params.clone()), + other => panic!("{:?}", other.as_ref().map(|d| d.name())), + }; + assert_eq!(then, "file.newFromTemplate"); + let mut with = params; + with["password"] = json!("sesame"); + a.execute(&then, with).unwrap(); + assert!(body_text(&a).contains("Locked template"), "{}", body_text(&a)); + assert_eq!((a.session.path.as_ref(), a.session.password.is_some()), (None, false)); + assert_eq!(a.session.documents().len(), 1); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn word_templates_are_told_by_their_extension() { + use crate::documents::is_word_template; + for t in ["a.dotx", "a.dotm", "a.dot", "A.DOTX", "/x/y.Dotm", "C:\\x\\y.DOT"] { + assert!(is_word_template(t), "{t}"); + } + for t in ["a.docx", "a.doc", "dotx", "a.dotx.docx", "a.ott", "", "a."] { + assert!(!is_word_template(t), "{t}"); + } +} + /// #55: a password-protected document asks for its password (after Save Changes) instead of /// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f69e584c4c..67d32e937d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,4 +1,6 @@ # File-format parity with Microsoft Word +> **2026-11-05:** A Word template opened from the file manager starts a new document based on it, and is never saved over (#3433). + > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously 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 @@ -68,7 +70,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read | | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | -| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | +| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | 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 | @@ -334,6 +336,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-04 | trivial | Engine field parsing and DOCX HYPERLINK targets consume quoted backslash escapes consistently | | 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks | | 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. | +| 2026-11-05 | trivial | `.dotx`/`.dotm`/`.dot`: a template the platform hands the app starts a new, untitled document attached to it, as Word's default verb does; File › Open still edits the template (#3433); fidelity estimate unchanged | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From 4821a13ee08b62231239d701784298ef395ada24 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:36:54 +0100 Subject: [PATCH 27/38] Font menus: entries are drawn from a few shared textures, so Windows no longer shows them on black, white or red strips (#5105) (#5111) * #5105: font menu entries are drawn from a few shared textures Each font menu entry was a texture of its own, created and freed as the list was browsed: on Windows (DirectX 12) the entries showed other textures' memory, black strips in light mode, white or red ones in dark mode, as the Equation tab's pictures did before #1002. They now share an atlas written in place, the one the equation pictures use (MathAtlas, now PreviewAtlas). Co-Authored-By: Claude Opus 5.5 * #5105: font menu entries are drawn again in every window once fonts change The font atlas remembers the font database generation its entries were drawn with and starts over when it changes, so another window's entries and a removed font's entry (Recently Used Fonts) don't keep an old face. Co-Authored-By: Claude Opus 5.5 * #5105: the atlas test doesn't compare with the shared font generation Review: tests running alongside add and remove fonts, so the database's generation may change between the draw and the assertion. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/ui-egui/src/app/fonts.rs | 2 +- crates/ui-egui/src/previews.rs | 130 ++++++++++++++++----------- crates/ui-egui/src/previews/tests.rs | 110 ++++++++++++++++++----- docs/ui-parity.md | 1 + 5 files changed, 169 insertions(+), 76 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 388ddcc08c..4b6cd435a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). - 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged. +- 2026-10-31: Font menus draw their entries from a few shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font: on Windows (DirectX 12) browsing the list showed the names on black strips in light mode and white or red ones in dark mode (#5105). Readiness unchanged. - 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. @@ -841,6 +842,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | | 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged | +| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index fc0370e164..54da1107ca 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -11,7 +11,7 @@ impl WordApp { } self.font_generation = generation; self.session.relayout(); - self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families())); + self.previews.fonts_changed(); ctx.request_repaint(); } diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 92bb56273f..5d02c834dd 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -28,30 +28,44 @@ pub struct Previews { budget: std::cell::Cell, frame: u64, /// The Equation tab's pictures, packed into one texture (#1002). - math: MathAtlas, + math: PreviewAtlas, + /// The font menus' entries, packed the same way (#5105); shared with the closures + /// [`Previews::font_preview_fn`] hands out. + fonts: Arc>, } -/// Side of each square texture page the equation pictures share, in pixels. -const MATH_ATLAS: usize = 2048; +/// The font menus' atlas, and the font database generation its entries were drawn with. When +/// fonts are added or removed, from this window or another one, every entry is drawn again: an +/// entry drawn in the fallback face before its font was added, or in a removed font's face (a +/// Recently Used Fonts entry), never stays. +#[derive(Default)] +struct FontAtlas { + generation: u64, + atlas: PreviewAtlas, +} + +/// Side of each square texture page the pictures of a [`PreviewAtlas`] share, in pixels. +const ATLAS: usize = 2048; /// At most this many pages: enough for a frame's pictures at a high display scale (the 22 /// built-in equations alone take two pages at 3×), still a handful of textures in all. -const MATH_ATLAS_PAGES: usize = 4; +const ATLAS_PAGES: usize = 4; /// Transparent pixels between pictures, so linear filtering never bleeds one into the next. -const MATH_ATLAS_GAP: usize = 2; +const ATLAS_GAP: usize = 2; -/// Every equation picture (gallery tiles, structure buttons, symbols) packed into a few shared -/// textures ("pages"), row by row, and drawn as a part of one (#1002). +/// Small pictures packed into a few shared textures ("pages"), row by row, and drawn as a part +/// of one: every equation picture (gallery tiles, structure buttons, symbols, #1002), and in an +/// atlas of their own the font menus' entries (#5105). /// -/// They used to be a texture each: hundreds of small textures, created as galleries opened and -/// all freed at once when the preview cache filled. On Windows (DirectX 12) the tiles then -/// showed other textures' memory: inverted pictures, red and teal fills, and even damaged -/// interface letters. A texture written in place is what egui does for its own glyphs; the -/// atlas creates at most [`MATH_ATLAS_PAGES`] textures and frees none while galleries are +/// They used to be a texture each: hundreds of small textures, created as galleries and font +/// menus were browsed and freed again. On Windows (DirectX 12) the pictures then showed other +/// textures' memory: inverted pictures (font names on black strips in light mode, white ones in +/// dark mode), red and teal fills, and even damaged interface letters. A texture written in place is what egui does for its own glyphs; the +/// atlas creates at most [`ATLAS_PAGES`] textures and frees none while galleries are /// browsed. When every page is full, the page least recently drawn from starts over, but never /// one drawn from in the current frame (its pictures are already on screen): a frame whose /// pictures don't fit shows the rest as linear text. #[derive(Default)] -struct MathAtlas { +struct PreviewAtlas { pages: Vec, /// Picture key → its page and part of that page's texture (UV coordinates). uv: HashMap, @@ -73,19 +87,19 @@ struct AtlasPage { impl AtlasPage { /// A free spot for a `w`×`h` picture, or `None` when the page is full or it can't fit. fn place(&mut self, w: usize, h: usize) -> Option<[usize; 2]> { - if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + if w == 0 || h == 0 || w > ATLAS || h > ATLAS { return None; } let (mut x, mut y, mut row_h) = (self.x, self.y, self.row_h); - if x.saturating_add(w) > MATH_ATLAS { + if x.saturating_add(w) > ATLAS { x = 0; - y = y.saturating_add(row_h).saturating_add(MATH_ATLAS_GAP); + y = y.saturating_add(row_h).saturating_add(ATLAS_GAP); row_h = 0; } - if y.saturating_add(h) > MATH_ATLAS { + if y.saturating_add(h) > ATLAS { return None; } - self.x = x.saturating_add(w).saturating_add(MATH_ATLAS_GAP); + self.x = x.saturating_add(w).saturating_add(ATLAS_GAP); self.y = y; self.row_h = row_h.max(h); Some([x, y]) @@ -97,12 +111,12 @@ impl AtlasPage { self.y = 0; self.row_h = 0; if let Some(tex) = &mut self.tex { - tex.set(egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR); + tex.set(egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR); } } } -impl MathAtlas { +impl PreviewAtlas { /// The picture stored under `key`, marking its page as drawn from in `frame`. fn get(&mut self, frame: u64, key: &str) -> Option<(egui::TextureId, Rect)> { let (page, uv) = *self.uv.get(key)?; @@ -116,11 +130,11 @@ impl MathAtlas { /// this frame without wiping pictures already handed out in it. fn add(&mut self, ctx: &egui::Context, frame: u64, key: &str, img: egui::ColorImage) -> Option<(egui::TextureId, Rect)> { let [w, h] = img.size; - if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + if w == 0 || h == 0 || w > ATLAS || h > ATLAS { return None; } let mut spot = self.pages.iter_mut().enumerate().find_map(|(i, p)| p.place(w, h).map(|at| (i, at))); - if spot.is_none() && self.pages.len() < MATH_ATLAS_PAGES { + if spot.is_none() && self.pages.len() < ATLAS_PAGES { let mut p = AtlasPage::default(); spot = p.place(w, h).map(|at| (self.pages.len(), at)); self.pages.push(p); @@ -138,18 +152,31 @@ impl MathAtlas { let p = self.pages.get_mut(i)?; let tex = p.tex.get_or_insert_with(|| { ctx.load_texture( - format!("math_atlas_{i}"), - egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), + format!("preview_atlas_{i}"), + egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR, ) }); tex.set_partial(at, img, egui::TextureOptions::LINEAR); p.used = frame; - let s = MATH_ATLAS as f32; + let s = ATLAS as f32; let uv = Rect::from_min_max(pos2(at[0] as f32 / s, at[1] as f32 / s), pos2((at[0] + w) as f32 / s, (at[1] + h) as f32 / s)); self.uv.insert(key.to_string(), (i, uv)); Some((tex.id(), uv)) } + + /// Forget every picture and clear the pages, keeping their textures. + fn clear(&mut self) { + for p in &mut self.pages { + p.reset(); + } + self.uv.clear(); + } +} + +/// A font menu entry's key in the fonts' atlas: its display scale and family. +fn font_key(name: &str, ppp: f32) -> String { + format!("{ppp}:{name}") } impl Previews { @@ -210,30 +237,37 @@ impl Previews { self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string()) } - /// Fonts were added or removed: list the families again and redraw the entries of - /// `families` (an entry drawn before its font was added shows the fallback face). - pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator) { + /// Fonts were added or removed: list the families again. The font menus' entries see the + /// change themselves ([`FontAtlas`]), in every window. + pub fn fonts_changed(&mut self) { self.families = None; self.east_asian = None; - ctx.data_mut(|d| { - for f in families { - d.remove::(egui::Id::unique(format!("font:{f}"))); - } - }); } - /// A closure drawing a font-menu entry in its own face. + /// A closure drawing a font-menu entry in its own face, from the fonts' atlas. pub fn font_preview_fn(&mut self) -> Box Response> { - Box::new(|ui: &mut Ui, name: &str| { - let key = format!("font:{name}"); - let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(&key))); + let atlas = Arc::clone(&self.fonts); + Box::new(move |ui: &mut Ui, name: &str| { + let ppp = ui.ctx().pixels_per_point(); + let frame = ui.ctx().cumulative_frame_nr(); + let key = font_key(name, ppp); let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); let t = Tokens::get(ui.ctx()); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let tex = tex.or_else(|| { + // The atlas lock is never held while the Context's is, nor the other way round. + let generation = wordcraft_fonts::FontDb::global().generation(); + let hit = { + let mut fonts = atlas.lock(); + if fonts.generation != generation { + fonts.generation = generation; + fonts.atlas.clear(); + } + fonts.atlas.get(frame, &key) + }; + let hit = hit.or_else(|| { // Context accessors share a lock: read input before taking the data write lock. let now = ui.input(|i| i.time); let n = ui.ctx().data_mut(|d| { @@ -248,22 +282,14 @@ impl Previews { ui.ctx().request_repaint(); return None; } - let ppp = ui.ctx().pixels_per_point(); let mut img = font_entry_image(name, ppp); ink_mask(&mut img); - let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone())); - Some(h) + atlas.lock().atlas.add(ui.ctx(), frame, &key, img) }); - match tex { - Some(h) => { - let sz = h.size_vec2() / ui.ctx().pixels_per_point(); - ui.painter().image( - h.id(), - Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - t.text, - ); + match hit { + Some((id, uv)) => { + let sz = uv.size() * ATLAS as f32 / ppp; + ui.painter().image(id, Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), uv, t.text); } None => { ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text); @@ -316,7 +342,7 @@ impl Previews { /// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in /// points), with its empty slots shown: the shared texture and the picture's UV rectangle in it -/// ([`MathAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting +/// ([`PreviewAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting /// for this frame's rendering budget. pub fn math_texture( app: &mut WordApp, diff --git a/crates/ui-egui/src/previews/tests.rs b/crates/ui-egui/src/previews/tests.rs index 31066daf38..7a56434542 100644 --- a/crates/ui-egui/src/previews/tests.rs +++ b/crates/ui-egui/src/previews/tests.rs @@ -121,10 +121,10 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { let (tx, rx) = std::sync::mpsc::channel(); let worker = std::thread::spawn(move || { let ctx = egui::Context::default(); - let preview = Previews::default().font_preview_fn(); + let mut previews = Previews::default(); + let preview = previews.font_preview_fn(); let budget_id = egui::Id::unique("font_preview_budget"); - let sans_id = egui::Id::unique("font:Source Sans 3"); - let inter_id = egui::Id::unique("font:Inter"); + let (sans, inter) = (font_key("Source Sans 3", 1.0), font_key("Inter", 1.0)); ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32))); for time in [1.0, 2.0, 3.0] { @@ -134,16 +134,16 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { preview(ui, "Inter"); }) .drop_without_applying_deltas(); - ctx.data(|d| { - assert!(d.get_temp::(sans_id).is_some()); - if time == 1.0 { - assert!(d.get_temp::(inter_id).is_none(), "over-budget entry should defer"); - assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13))); - } else { - assert!(d.get_temp::(inter_id).is_some(), "deferred entry should render next frame"); - assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget"); - } - }); + let drawn = |key: &str| previews.fonts.lock().atlas.uv.contains_key(key); + assert!(drawn(&sans)); + let budget = ctx.data(|d| d.get_temp::<(f64, u32)>(budget_id)); + if time == 1.0 { + assert!(!drawn(&inter), "over-budget entry should defer"); + assert_eq!(budget, Some((1.0, 13))); + } else { + assert!(drawn(&inter), "deferred entry should render next frame"); + assert_eq!(budget, Some((2.0, 1)), "cached entries should not consume budget"); + } } tx.send(()).unwrap(); }); @@ -262,12 +262,12 @@ fn equation_pictures_share_one_texture() { fn math_atlas_packs_rows() { let mut a = AtlasPage::default(); assert_eq!(a.place(100, 40), Some([0, 0])); - assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0])); - assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest"); - assert_eq!(a.place(MATH_ATLAS + 1, 10), None); + assert_eq!(a.place(100, 20), Some([100 + ATLAS_GAP, 0])); + assert_eq!(a.place(ATLAS, 10), Some([0, 40 + ATLAS_GAP]), "a new row under the tallest"); + assert_eq!(a.place(ATLAS + 1, 10), None); assert_eq!(a.place(10, 0), None); - assert_eq!(a.place(10, MATH_ATLAS), None, "full"); - assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal"); + assert_eq!(a.place(10, ATLAS), None, "full"); + assert_eq!(a.place(10, 10), Some([0, 40 + ATLAS_GAP + 10 + ATLAS_GAP]), "still room after a refusal"); } /// #1002: a page is never wiped in a frame that handed out one of its pictures (they are @@ -275,16 +275,16 @@ fn math_atlas_packs_rows() { #[test] fn math_atlas_never_wipes_a_page_drawn_from_this_frame() { let ctx = egui::Context::default(); - let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT); + let img = || egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let mut a = MathAtlas::default(); - let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect(); + let mut a = PreviewAtlas::default(); + let first: Vec<_> = (0..ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect(); assert!(first.iter().all(Option::is_some), "a page each"); let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect(); - assert_eq!(ids.len(), MATH_ATLAS_PAGES); + assert_eq!(ids.len(), ATLAS_PAGES); assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame"); assert_eq!(a.wipes, 0); - assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten"); + assert!(a.uv.len() == ATLAS_PAGES, "nothing forgotten"); // Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1. assert_eq!(a.get(2, "p0"), first.first().copied().flatten()); let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over"); @@ -347,6 +347,70 @@ fn ink_mask_keeps_the_ink_and_drops_the_paper() { assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage"); } +/// #5105: on Windows, font menu entries showed on black strips in light mode and on white or +/// red ones in dark mode: each entry was a texture of its own, and browsing the list created +/// and freed hundreds of them, the DirectX 12 texture mix-up of #1002. Entries are now drawn +/// from a few shared pages written in place: scrolling through a long font list, at a high +/// display scale too, creates at most [`ATLAS_PAGES`] textures and frees none. Once fonts are +/// added or removed (from any window), every entry is drawn again. +#[test] +fn font_menu_entries_share_a_few_textures_and_free_none() { + for ppp in [1.0, 3.0] { + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(ppp); + let mut previews = Previews::default(); + let preview = previews.font_preview_fn(); + let names: Vec = (0..300).map(|i| format!("Font {i}")).collect(); + let (mut created, mut freed, mut ids) = (std::collections::HashSet::new(), 0, std::collections::HashSet::new()); + // A menu shows about fifteen entries a frame; scroll it through the list twice. + for (frame, rows) in names.chunks(15).chain(names.chunks(15)).enumerate() { + let input = egui::RawInput { time: Some(frame as f64), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + for name in rows { + preview(ui, name); + } + }); + created.extend( + out.textures_delta + .set + .iter() + .filter(|(id, ds)| **id != egui::TextureId::default() && ds.iter().any(|d| d.pos.is_none())) + .map(|(id, _)| *id), + ); + freed += out.textures_delta.free.len(); + for s in &out.shapes { + if let egui::Shape::Mesh(m) = &s.shape + && m.texture_id != egui::TextureId::default() + { + ids.insert(m.texture_id); + } + } + out.textures_delta.clear(); + } + assert!(!created.is_empty() && created.len() <= ATLAS_PAGES, "{ppp}: {} textures created", created.len()); + assert_eq!(freed, 0, "{ppp}: no texture is freed while the list is browsed"); + assert!(ids.len() <= ATLAS_PAGES, "{ppp}: entries drawn from {} textures", ids.len()); + // An entry is drawn from the atlas, not as plain text. + ctx.run_ui(egui::RawInput { time: Some(1000.0), ..Default::default() }, |ui| { + preview(ui, "Font 298"); + preview(ui, "Font 299"); + }) + .drop_without_applying_deltas(); + assert!(previews.fonts.lock().atlas.uv.contains_key(&font_key("Font 299", ppp))); + // Fonts were added or removed since the entries were drawn: the next entry drawn starts + // the atlas over, so none keeps a face it no longer has. + previews.fonts.lock().generation = u64::MAX; + ctx.run_ui(egui::RawInput { time: Some(1001.0), ..Default::default() }, |ui| { + preview(ui, "Font 298"); + }) + .drop_without_applying_deltas(); + let fonts = previews.fonts.lock(); + // Not compared with the database's generation: tests running alongside add and remove fonts. + assert_ne!(fonts.generation, u64::MAX, "the atlas took the database's generation"); + assert!(fonts.atlas.uv.contains_key(&font_key("Font 298", ppp)) && !fonts.atlas.uv.contains_key(&font_key("Font 299", ppp))); + } +} + /// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the /// theme's text colour and draw no background of their own. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 833118b774..a92e3a724d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -111,6 +111,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-28 | trivial | Home › Paragraph › Shading: text selected within part of one paragraph is shaded itself, as in Word, so No Color removes text shading imported from a file (#4685) | | 2026-11-03 | trivial | Search ("Tell me"): a click on the title bar's Search box, or Alt+Q, types into that box itself, as in Word, with the suggestions and results (commands, then Find in the document) dropping down under it; Enter runs the first, Escape or a click elsewhere closes it. It used to open a second box in a Search Commands dialog, which is still what shows where the box isn't drawn (#5398) | +| 2026-10-31 | trivial | Font menus (ribbon, mini toolbar, Font and other dialogs): entries are drawn from at most four shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font; on Windows (DirectX 12) they showed on black strips in light mode and white or red ones in dark mode (#5105) | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | From 4ba444a3429bd4ca531a96bbd840e716ead6c510 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:39:17 +0100 Subject: [PATCH 28/38] Table Sort: validate explicit columns (#5531) (#5533) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/table/convert.rs | 9 +++- crates/engine/src/tests.rs | 67 ++++++++++++++++++++++++++ docs/gaps.md | 3 ++ docs/ui-parity.md | 3 ++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b6cd435a2..b6f8ec3c4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -220,6 +220,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. - 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result. - 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal. +- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged. - 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. - 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 6734e050c4..925e1f6cab 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { }) .params(r#"{"text"?: string, "title"?: string} (empty clears)"#)), t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table) - .params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), + .params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)), t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text) .params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)), t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)), diff --git a/crates/engine/src/cmd/table/convert.rs b/crates/engine/src/cmd/table/convert.rs index f8cb418863..e92ce29d9e 100644 --- a/crates/engine/src/cmd/table/convert.rs +++ b/crates/engine/src/cmd/table/convert.rs @@ -8,11 +8,18 @@ use super::{ /// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and /// a half in Polish) and sort first, numerically; other text sorts in the column's language. pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult { - let col = p::u64(v, "column").unwrap_or(0) as usize; + let column = match v.get("column") { + None => 0, + Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?, + }; let desc = p::bool(v, "descending").unwrap_or(false); let header = p::bool(v, "header"); let (tp, _, _) = cell(s)?; let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; + let col = usize::try_from(column) + .ok() + .filter(|&col| t.rows.iter().any(|row| col < row.cells.len())) + .ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?; let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len()); let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col)); let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf37b90205..d51a5df56f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10389,6 +10389,73 @@ fn column_texts(s: &Session) -> Vec { t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::()).collect() } +#[test] +fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { + for column in [ + json!(-1), + json!(0.5), + json!(0.0), + json!("0"), + json!(null), + json!(true), + json!([]), + json!({}), + json!(1), + json!(u32::MAX as u64 + 1), + json!(u64::MAX), + ] { + let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); + s.close_typing(); + run(&mut s, "text.insert", json!({"text": "!"})); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let dirty = s.dirty; + assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); + assert_eq!(s.doc, before, "column {column}"); + assert_eq!(s.sel, selection, "column {column}"); + assert_eq!(s.dirty, dirty, "column {column}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); + } +} + +#[test] +fn table_sort_keeps_default_and_valid_columns() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { + if k > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": value})); + } + let original = s.doc.clone(); + for params in [json!({"header": false}), json!({"column": 0, "header": false})] { + run(&mut s, "table.sort", params); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, original); + } + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + // A column may be missing in some rows. Its present cells still sort normally. + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); + table.rows[0].cells.remove(1); + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + + let mut s = column_table("en-US", &["Title", "3", "1", "2"]); + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; + run(&mut s, "table.sort", json!({"column": 0})); + assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); +} + /// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish. #[test] fn table_sort_reads_decimal_comma_in_polish() { diff --git a/docs/gaps.md b/docs/gaps.md index 66f41b99e7..e3866b517d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -18,6 +18,8 @@ Group connectors (gap 10): re-routing preserves displayed rotated and flipped fr > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. +Table Sort rejects invalid explicit column selectors instead of silently sorting another column (#5531). This command-boundary correction leaves the ranked gaps and readiness estimates unchanged. + The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs @@ -214,6 +216,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged | | 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged | | 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | +| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. | | 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) | | 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too | | 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a92e3a724d..0d64fef907 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -77,6 +77,8 @@ Save Changes exposes the document-specific question as an alert-dialog name and The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate. +The `table.sort` command requires an explicit `column` to be a non-negative JSON integer naming a column in the table, indexed from 0 (#5531). Omitting it still sorts by column 0. Invalid selectors return an error before changing rows or history; the Sort dialog and collation rules are unchanged. + ## Find and Replace: regular expressions (#4177) With **Regular expressions** on (Search Options), Replace with expands capture groups: `$1`…`$n`, `${n}`, `${name}` and `$name` (the longest run of letters, digits and `_`), `$0` / `${0}` (the whole match), and `\1`…`\9` as in wildcard Replace; `$$` is a dollar sign, `\t` a tab, `\\` a backslash, `\$` and `\^` a `$` or `^` as typed, and `\n` a paragraph mark, which splits the paragraph as `^p` does (Replace All stays one undo step). Word's `^&`, `^p`, `^t`, `^c` and the other `^` codes work there too (with Special characters off, only the `^` codes are taken literally). A group that doesn't exist, didn't take part in the match or is out of range expands to nothing; a `$` that starts no group, an unclosed `${`, and other `\` escapes stay as written. Plain and wildcard Replace are unchanged. Word has no regular-expression mode; this follows the Rust `regex` crate's Replace syntax. @@ -117,6 +119,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | | 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts | | 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland | +| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. | | 2026-11-03 | trivial | Comments: a long comment's card, balloon and fields stop at ten lines; the fields scroll, and a balloon that isn't selected shows the first ten lines of the comment and of each reply (#3382) | | 2026-11-03 | trivial | Over a turned picture, shape or text box, the pointer on a resize handle shows the arrow along which that handle resizes: turned 90°, the handle now at the top shows an up-down arrow (it showed left-right), turned 45° the middle handles show diagonal ones; also while the handle is dragged (#3932) | | 2026-11-03 | trivial | Shape Format › Size: the width and height show the interface unit and take a size typed in any unit (`3 cm`, `300 px`), applied once typed (Enter, Tab or a click elsewhere; Esc cancels), each side on its own, as Picture Format › Size does; in both, a field clicked and left without typing no longer resizes the object by the rounding of the size it shows (#4196) | From 6b163b33dc4dc3e64273f647dc594d3bdea5a470 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 7 Nov 2026 21:39:35 -0500 Subject: [PATCH 29/38] xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files (#5471) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/ui-egui/src/paste_picture.rs | 10 ++++++++-- xtask/src/assets.rs | 6 ++++-- xtask/src/stats.rs | 20 ++++++++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b6f8ec3c4d..9445deb18c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -226,6 +226,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. - 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged. - 2026-11-03: Shape Format › Size: the width and height fields show the interface unit (centimetres, inches…) instead of always points, and take a size typed in any unit, such as `3 cm` or `300 px`, applied once typed (Esc cancels), each side on its own, as Picture Format › Size does (#4196). In both, a field clicked and left with Enter or Tab without typing no longer resizes the object by the rounding of the size it shows (a 100 pt shape became 100.08 pt in inches). +- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index 34172cc1c3..ddbe198072 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -147,8 +147,14 @@ pub(crate) fn paste(app: &mut WordApp) -> Option> { } let mut r = Err(String::new()); for path in files { - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - r = if size > MAX_FILE_BYTES { + // A clipboard path can name a pipe or device that reports no length and never ends. + let md = std::fs::metadata(&path).ok(); + let size = md.as_ref().map_or(0, |m| m.len()); + r = if md.as_ref().is_some_and(|m| !m.is_file()) { + let e = format!("{} is not a regular file", path.display()); + app.status(e.clone()); + Err(e) + } else if size > MAX_FILE_BYTES { let e = format!("{} is larger than 200 MB", path.display()); app.status(e.clone()); Err(e) diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index e962f6ef57..4c10c8e8ed 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -73,8 +73,10 @@ mod tests { fn a_failed_file_listing_fails_the_check() { // #1364: outside a git checkout `git ls-files` fails, and its empty output read as "no // files" passed the check. - let dir = std::env::temp_dir().join(format!("wordcraft-xtask-assets-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + // Created exclusively under a name no one can predict: an existing folder or link there would be someone else's. + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("wordcraft-xtask-assets-{}-{nanos}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("ATTRIBUTION.md"), "").unwrap(); let inside_git = Command::new("git").current_dir(&dir).args(["rev-parse", "--git-dir"]).output().is_ok_and(|o| o.status.success()); let result = run(&dir); diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300ac..8abfea4e58 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -18,12 +18,14 @@ fn walk(dir: &Path, c: &mut Counts) { let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - if p.is_dir() { + // A symlink is never followed: several links to an ancestor would multiply the walk. + if e.file_type().is_ok_and(|t| t.is_dir()) { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { walk(&p, c); } } else if name.ends_with(".rs") + && e.file_type().is_ok_and(|t| t.is_file()) && let Ok(s) = std::fs::read_to_string(&p) { c.files += 1; @@ -108,11 +110,25 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests { - use super::count_tests; + use super::{Counts, count_tests, walk}; #[test] fn counts_attributes_and_proptest_blocks() { let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; assert_eq!(count_tests(src), (2, 1)); } + + #[cfg(unix)] + #[test] + fn a_walk_does_not_follow_directory_links() { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("wordcraft-xtask-stats-{}-{nanos}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("a.rs"), "#[test]\n").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("loop")).unwrap(); + let mut c = Counts::default(); + walk(&dir, &mut c); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!((c.files, c.tests), (1, 1)); + } } From 050b6b0da33ae99444aad26a2df8d745586ccd5f Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:44:01 +0100 Subject: [PATCH 30/38] Arabic letters join across a change of formatting (#4093) (#5691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Arabic letters join across a change of formatting (#4093) Each run of a paragraph was shaped on its own, so a letter at a run's edge took the form of a word's start or end. A coloured letter stood apart from its word, and a word edited with Track Changes on read with a space in it (ال ذكاء). Runs are now shaped with the text shown on either side of them, through the shaper's pre- and post-context (fonts::ShapeContext). Runs left out of the layout (hidden text, deletions in a final view) are skipped, so letters join what is on the page. Only paragraphs with a joining script do the work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk * #4093 review: no letter joins a drop cap formatted on its own A drop cap letter in a run of its own (coloured, say) was still passed to the next run as the text before it, so that run's first letter joined the drop cap, which is drawn apart from the word. The shown text now has a floor past the drop cap, and no run takes context from before it. The drop cap test also covers the letter in a run of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk --------- Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/fonts/src/lib.rs | 62 ++++++++++-- crates/fonts/src/script.rs | 22 +++++ crates/layout/src/para/builder.rs | 23 ++++- crates/layout/src/para/paragraph.rs | 58 ++++++++++- crates/layout/src/tests/bidi.rs | 146 ++++++++++++++++++++++++++++ docs/gaps.md | 4 +- docs/layout-parity.md | 3 + docs/localization-parity.md | 3 +- 9 files changed, 309 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9445deb18c..3c31857b74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. - 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it. +- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -825,6 +826,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. > 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. +> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged. > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index b99e03b8ff..7305f5af5b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -21,7 +21,7 @@ pub use fontdb::{ pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer}; pub use kurbo::BezPath; -pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks}; +pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; pub use ui_fallback::{ @@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range, bool)> { /// order: right-to-left runs are shaped right to left, then their clusters put back in text /// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually. pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec { + shape_in(face, text, features, map, ShapeContext::default()) +} + +/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the +/// letters of an Arabic word join across a change of formatting, so each piece has to know what +/// it continues (#4093). Nothing outside the shaped text is drawn or measured. +#[derive(Clone, Copy, Debug, Default)] +pub struct ShapeContext<'a> { + pub before: &'a str, + pub after: &'a str, +} + +impl<'a> ShapeContext<'a> { + /// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge. + fn at_edges_of(self, text: &str, r: &std::ops::Range) -> ShapeContext<'a> { + ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } } + } +} + +/// [`shape`] of a piece of longer text (see [`ShapeContext`]). +pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec { if !text.chars().any(is_rtl) { - return shape_dir(face, text, features, &map, false); + return shape_dir(face, text, features, &map, false, around); } let mut out = Vec::with_capacity(text.len()); for (r, rtl) in direction_runs(text) { let Some(sub) = text.get(r.clone()) else { continue }; - let mut g = shape_run(face, sub, features, &map, rtl); + let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r)); for x in &mut g { x.cluster += r.start; } @@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha /// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order, /// each cluster's glyphs in the shaper's (visual) order. pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec { - let g = shape_dir(face, text, features, &map, rtl); + shape_run_in(face, text, features, map, rtl, ShapeContext::default()) +} + +/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]). +pub fn shape_run_in( + face: &FontFace, + text: &str, + features: &[Feature], + map: impl Fn(char) -> char, + rtl: bool, + around: ShapeContext<'_>, +) -> Vec { + let g = shape_dir(face, text, features, &map, rtl, around); if !rtl { return g; } @@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn groups.into_iter().flatten().collect() } -fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec { +fn shape_dir( + face: &FontFace, + text: &str, + features: &[Feature], + map: &impl Fn(char) -> char, + rtl: bool, + around: ShapeContext<'_>, +) -> Vec { let mut out = Vec::with_capacity(text.len()); let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); @@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch for (i, c) in sub.char_indices() { buf.add(map(c), (r.start + i) as u32); } + // After the text: adding to a buffer clears what follows it. + let around = around.at_edges_of(text, &r); + buf.set_pre_context(around.before); + buf.set_post_context(around.after); buf.guess_segment_properties(); buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); let plan = shape_plan(face, &shaper, &buf, features); @@ -386,7 +430,10 @@ mod tests { gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect() }; let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> { - shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect() + shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default()) + .iter() + .map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)) + .collect() }; let db = FontDb::global(); let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")]; @@ -421,7 +468,8 @@ mod tests { for n in 0..3 * MAX_SHAPE_PLANS as u32 { let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)]; let rtl = n % 3 == 0; - let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect(); + let kept: Vec<(u32, i32)> = + shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect(); assert_eq!(kept, fresh(&features, rtl), "{n}"); assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans()); } diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index e97de89ce7..51c9d252c5 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -18,6 +18,28 @@ fn real_script(c: char) -> Option