From 220bcf947a4483dbaa2c2430cbd338917d9a784c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 31 Oct 2026 17:28:29 +0100 Subject: [PATCH 01/47] =?UTF-8?q?Home=20=E2=80=BA=20Paragraph=20Tools:=20n?= =?UTF-8?q?ine=20WPS-style=20paragraph=20commands=20(#5043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversions: line breaks to paragraphs, blank paragraphs between, separate paragraphs, spaces to indentation, indents to leading spaces, join lines. Cleanup: delete blank paragraphs, remove leading spaces, collapse extra spaces. Each command works over the selection, or the whole document with scope "all"; one undo step per command; deletions are recorded under Track Changes. Home ribbon drop-down with per-command entries, including whole- document variants. 16 engine tests, 1 ribbon kittest. Closes #5043 --- ROADMAP.md | 3 +- crates/engine/src/cmd/para.rs | 25 +++ crates/engine/src/cmd/para/tools.rs | 266 ++++++++++++++++++++++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/para_tools.rs | 202 +++++++++++++++++++ crates/ui-egui/src/ribbon/home.rs | 26 +++ crates/ui-egui/src/ribbon/tests.rs | 44 +++++ 7 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/para/tools.rs create mode 100644 crates/engine/src/tests/para_tools.rs diff --git a/ROADMAP.md b/ROADMAP.md index 90c51e17dc..801736e8d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Home › Paragraph Tools: nine paragraph conversion and cleanup commands, #5043, with a Home ribbon drop-down; readiness estimates unchanged); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -680,6 +680,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Home › Paragraph Tools (#5043): nine commands — line breaks to paragraphs, blank paragraphs between, separate paragraphs, delete blanks, trim leading spaces, collapse extra spaces, spaces to indent, indents to spaces, join lines; scope is the selection or the whole document; one undo step per command, Track Changes deletions recorded; 16 engine tests and a ribbon drop-down test; Word feature catalog unchanged (WPS-style tools); readiness estimates unchanged | | 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) | | 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) | | 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) | diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 40cd997043..6e987d1fd4 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -15,6 +15,7 @@ mod lists; mod outline; mod styles; mod tabs_borders; +mod tools; use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog}; pub use format::{fmt, indent, outdent}; @@ -25,6 +26,10 @@ use outline::{move_paras, outline_step, sort}; use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match}; pub use tabs_borders::border_sides; use tabs_borders::{borders, tabs}; +use tools::{ + blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent, + trim_leading, +}; pub fn specs() -> Vec { vec![ @@ -261,5 +266,25 @@ pub fn specs() -> Vec { } sel_result(s) }), + // Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the + // selection, or the whole document with `scope: "all"`. One undo step per command. + CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between) + .params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#), + CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks) + .params(r#"{"scope"?: "selection|all"}"#), ] } diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs new file mode 100644 index 0000000000..f7f208bb9c --- /dev/null +++ b/crates/engine/src/cmd/para/tools.rs @@ -0,0 +1,266 @@ +//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's +//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole +//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes. + +use serde_json::{Value, json}; + +use wordcraft_doc::para::{LINE_BREAK, PAGE_BREAK}; +use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef}; + +use super::{CmdError, CmdResult, Session, p, sel_result}; +use crate::cmd::{has_next_para, new_revision, split_para, track_delete_as}; + +/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a +/// command can add, stay bounded whatever the document says. +const MAX_TOOL_PARAS: usize = 10_000; + +/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says. +const MAX_INDENT_SPACES: usize = 1_000; + +/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default. +/// `all` selects the whole document first, so the command, its undo step and the result all +/// follow the full selection like any other command. +fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> { + match p::str(v, "scope").unwrap_or("selection") { + "selection" => Ok(()), + "all" => { + s.run("select.all", &json!({}))?; + Ok(()) + } + x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))), + } +} + +/// The paragraphs a command runs over, in document order, and the story they are in. +fn targets(s: &Session) -> Result<(StoryRef, Vec), CmdError> { + let (a, b) = s.sel.ordered(); + let paths = s.doc.para_paths_in(&a, &b); + if paths.is_empty() { + return Err(CmdError::Failed("no paragraphs selected".into())); + } + if paths.len() > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command"))); + } + Ok((a.story, paths)) +} + +/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on. +/// Offsets past the paragraph's end are clamped, so the document is never half-edited. +fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> { + let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len(); + let (a, b) = (a.min(b).min(len), b.min(len)); + if a >= b { + return Ok(()); + } + let at = |off: usize| Pos { story, path: path.clone(), off }; + if s.doc.settings.track_changes { + let rid = new_revision(s, RevisionKind::Delete); + track_delete_as(s, &at(a), &at(b), rid)?; + } else { + s.doc.delete_range(&at(a), &at(b))?; + } + Ok(()) +} + +/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied +/// last first so the earlier offsets stay valid. +fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> { + ranges.sort(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (a, b) in ranges { + match merged.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => merged.push((a, b)), + } + } + for (a, b) in merged.into_iter().rev() { + delete_in_para(s, story, path, a, b)?; + } + Ok(()) +} + +/// The byte ranges of every manual line break in the paragraph. +fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> { + para.text.char_indices().filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect() +} + +/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a +/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it. +pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + for (i, _) in break_ranges(para).into_iter().rev() { + let new = split_para(s, &Pos { story, path: path.clone(), off: i })?; + // The break stays at the head of the new paragraph; the paragraph break replaces it. + delete_in_para(s, story, &new.path, 0, 1)?; + } + } + sel_result(s) +} + +/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after` +/// adds one blank paragraph after the last selected paragraph too. +pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult { + let after = p::bool(v, "after").unwrap_or(false); + scope_all(s, v)?; + let (story, paths) = targets(s)?; + let count = paths.len().saturating_sub(1) + usize::from(after); + if count > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once"))); + } + // Last first, each split at a paragraph's end: a split only shifts the paths after it. + if after { + let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?; + let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: last.clone(), off: len })?; + } + for path in paths[..paths.len().saturating_sub(1)].iter().rev() { + let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: path.clone(), off: len })?; + } + sel_result(s) +} + +/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a +/// blank line between them. +pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult { + blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true})) +} + +/// `para.removeBlank`: delete every empty paragraph in the selection. +pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + // Last first: removing a block leaves the paths before it as they are. + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + // A paragraph must be empty, carry no section break and have a paragraph after it in + // its container: a story's, part's or cell's last mark can't be removed, and a paragraph + // before a table can't be joined across it. + if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) { + continue; + } + if s.doc.settings.track_changes { + let next = path.with_last(path.last() + 1); + let rid = new_revision(s, RevisionKind::Delete); + track_delete_as(s, &Pos { story, path: path.clone(), off: 0 }, &Pos { story, path: next, off: 0 }, rid)?; + } else { + s.doc.remove_block(story, path)?; + } + } + sel_result(s) +} + +/// The number of leading spaces in the paragraph's text. +fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize { + para.text.chars().take_while(|c| *c == ' ').count() +} + +/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph. +pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let n = leading_spaces(para); + apply_deletions(s, story, &path, vec![(0, n)])?; + } + sel_result(s) +} + +/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one +/// space; tabs and page breaks count as spaces, so spaces around a tab collapse to it. +/// Leading spaces stay (that is `para.trimLeadingSpaces`). +pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let mut runs = Vec::new(); + let mut start = None; + for (i, c) in para.text.char_indices() { + if matches!(c, ' ' | '\t' | PAGE_BREAK) { + start.get_or_insert(i); + } else if let Some(a) = start.take() { + // A run at the paragraph's start stays; a run inside the text collapses to one space. + if a > 0 && i - a > 1 { + runs.push((a, i - 1)); + } + } + } + // A run that reaches the paragraph's end collapses to one space too. + if let Some(a) = start + && a > 0 + && para.len() - a > 1 + { + runs.push((a + 1, para.len())); + } + apply_deletions(s, story, &path, runs)?; + } + sel_result(s) +} + +/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt +/// per space (Word's point-based indent from typing spaces), and are deleted. +pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let spaces = leading_spaces(para); + if spaces == 0 { + continue; + } + { + let p = s.doc.para_mut(story, &path)?; + p.props.indent_first = Some((p.props.indent_first.unwrap_or(0.0) + spaces as f32 * 72.0).clamp(-1584.0, 1584.0)); + p.props.cancel_char_indents(false, false, true); + p.touch(); + } + // A space is one byte, so the byte range is the count. + apply_deletions(s, story, &path, vec![(0, spaces)])?; + } + sel_result(s) +} + +/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces +/// (72 pt per space, as typing spaces indents), and the indents go to zero. +pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0); + let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize; + if n == 0 { + continue; + } + let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default(); + // The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow. + // The spaces are marked as a tracked insertion through `props.ins`, as typing is. + if s.doc.settings.track_changes { + props.ins = Some(new_revision(s, RevisionKind::Insert)); + } + let p = s.doc.para_mut(story, &path)?; + p.insert_text(0, &" ".repeat(n), &props)?; + p.props.indent_first = Some(0.0); + p.props.indent_left = Some(0.0); + p.props.cancel_char_indents(true, false, true); + p.touch(); + } + sel_result(s) +} + +/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines +/// join into one paragraph. +pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + apply_deletions(s, story, &path, break_ranges(para))?; + } + sel_result(s) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6cc770c64b..7fad83f443 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,7 @@ mod documents; mod font_sizes; mod fonts; mod ime_noop; +mod para_tools; mod revision_checks; mod typing; mod wordart; diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs new file mode 100644 index 0000000000..aa2292fcab --- /dev/null +++ b/crates/engine/src/tests/para_tools.rs @@ -0,0 +1,202 @@ +//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole +//! document, one undo step, Track Changes respected. + +use serde_json::json; +use wordcraft_doc::{Path, Pos, RevisionKind, SectionProps, StoryRef}; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: serde_json::Value) { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +/// Select the whole document (Mod+A's command). +fn select_all(s: &mut Session) { + run(s, "select.all", json!({})); +} + +/// One paragraph with `\n` as manual line breaks, typed as text. +fn type_breaks(s: &mut Session, t: &str) { + run(s, "text.insert", json!({"text": t})); +} + +#[test] +fn line_breaks_become_paragraphs() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); + // One undo step brings the manual line breaks back. + assert!(s.undo()); + let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); +} + +#[test] +fn blank_paragraphs_go_in_between() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc"); +} + +#[test] +fn blank_paragraphs_go_after_too() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn separate_paragraphs_blanks_after_every_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn blank_paragraphs_are_deleted() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn a_section_break_paragraph_stays() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb"})); + // The second paragraph carries a section break and is emptied: removing it would drop the + // section, so it stays. + let second = Path::top(1); + let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); + p.text.clear(); + p.runs.clear(); + p.section = Some(Box::new(SectionProps::default())); + p.touch(); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); +} + +#[test] +fn leading_spaces_are_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn extra_spaces_collapse_to_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); +} + +#[test] +fn a_space_run_at_the_end_collapses() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a "})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a "); +} + +#[test] +fn indented_spaces_become_an_indent() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb"})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); + assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); +} + +#[test] +fn indents_become_leading_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); +} + +#[test] +fn line_breaks_are_removed() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + select_all(&mut s); + run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "onetwothree"); +} + +#[test] +fn the_selection_is_the_scope() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\nc d"})); + // Only the first paragraph: select from its start to its end. + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; + run(&mut s, "para.removeExtraSpaces", json!({})); + assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); +} + +#[test] +fn deletions_are_recorded_under_track_changes() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\n c"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + // The text stays marked deleted, not gone, as Word records the deletion. + assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); + assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); +} + +#[test] +fn junk_scope_is_refused() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + select_all(&mut s); + let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); + assert!(format!("{e}").contains("scope"), "{e}"); + assert_eq!(text(&s), "a b", "the document is unchanged"); +} + +#[test] +fn too_many_paragraphs_are_refused_before_any_change() { + let text = "x\n".repeat(15_000); + let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); + select_all(&mut s); + let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); + assert!(format!("{e}").contains("at most"), "{e}"); + let kept = s.doc.para_paths(StoryRef::Body).len(); + assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 1eb15a6772..114076437f 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -328,6 +328,32 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) { }); }); }); + // Advanced paragraph tools (#5043): conversions and cleanup over the selection, or the + // whole document with Ctrl+A first (the commands take `scope: "all"` for that as well). + group(ui, "Paragraph Tools", None, app, |ui, app| { + menu_button(ui, app, crate::widgets::MenuButtonOptions::new("pilcrow", Some("Paragraph\nTools"), "Paragraph Tools", true, ""), |ui, app| { + mi(ui, app, "Split Lines into Paragraphs", "para.convertBreaks", json!({})); + mi(ui, app, "Add Blank Paragraphs Between", "para.blankBetween", json!({})); + mi(ui, app, "Separate Paragraphs", "para.separateParagraphs", json!({})); + ui.separator(); + mi(ui, app, "Delete Blank Paragraphs", "para.removeBlank", json!({})); + mi(ui, app, "Remove Leading Spaces", "para.trimLeadingSpaces", json!({})); + mi(ui, app, "Remove Extra Spaces", "para.removeExtraSpaces", json!({})); + ui.separator(); + mi(ui, app, "Indent Leading Spaces", "para.spacesToIndent", json!({})); + mi(ui, app, "Indents to Leading Spaces", "para.indentToSpaces", json!({})); + mi(ui, app, "Join Lines", "para.removeLineBreaks", json!({})); + ui.separator(); + // The whole document, not just the selection. + for (l, id) in [ + ("Delete Blank Paragraphs in the Whole Document", "para.removeBlank"), + ("Remove Leading Spaces in the Whole Document", "para.trimLeadingSpaces"), + ("Remove Extra Spaces in the Whole Document", "para.removeExtraSpaces"), + ] { + mi(ui, app, l, id, json!({"scope": "all"})); + } + }); + }); group(ui, "Styles", Some("styles.pane"), app, |ui, app| { crate::previews::style_gallery(app, ui, &st); }); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index 1f1aeab9db..302fcb887e 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -98,6 +98,50 @@ fn design_style_set_gallery_stays_in_its_box() { assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } +/// #5043: Home › Paragraph Tools: the drop-down's entries each run their own command over the +/// selection; "in the Whole Document" entries run the same command with `scope: "all"`. +#[test] +fn paragraph_tools_menu_entries_run_their_commands() { + use egui_kittest::kittest::Queryable; + let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a b\n\nc d")), crate::Services::default()); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(900.0, 300.0)) + .with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(false, false))) + .build_ui_state(|ui, app: &mut WordApp| super::home::home(app, ui), a); + h.run_steps(2); + // The accessible label is the menu button's tip, "Paragraph Tools". + h.get_by_label("Paragraph Tools").click(); + h.run_steps(2); + // Items carry their (empty) shortcut text in the label, so match by containment. + for name in [ + "Split Lines into Paragraphs", + "Add Blank Paragraphs Between", + "Separate Paragraphs", + "Delete Blank Paragraphs", + "Remove Leading Spaces", + "Remove Extra Spaces", + "Indent Leading Spaces", + "Indents to Leading Spaces", + "Join Lines", + "Delete Blank Paragraphs in the Whole Document", + "Remove Leading Spaces in the Whole Document", + "Remove Extra Spaces in the Whole Document", + ] { + assert!(!h.query_all_by_label_contains(name).collect::>().is_empty(), "{name} is a named entry"); + } + // A caret, not a selection: the whole-document entries cover both paragraphs. + h.get_by_label_contains("Remove Extra Spaces in the Whole Document").click(); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "a b\n\nc d"); + // The menu closed with the click; open it again for the next entry. + h.get_by_label("Paragraph Tools").click(); + h.run_steps(2); + // Both entries match by containment; the plain one is first in the menu. + h.query_all_by_label_contains("Delete Blank Paragraphs").min_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().session.undo_depth(), 2, "the second entry is its own undo step"); +} + /// #2738: Home › Underline's drop-down: every line sample is a named entry (assistive /// technology, tooltips) that applies its own style; Words only, None and Underline Color › /// Automatic do what they say. From 9e391e97f42eba5b40a6370ce7be9e1d2caa64d8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 02:04:56 +0100 Subject: [PATCH 02/47] Paragraph tools: skip tracked-deleted characters; snapshot revisions once per command (review) Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/fmt_revisions.rs | 30 ++++++++---- crates/engine/src/cmd/para/tools.rs | 63 ++++++++++++++++---------- crates/engine/src/tests/para_tools.rs | 47 +++++++++++++++++++ 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/fmt_revisions.rs b/crates/engine/src/cmd/fmt_revisions.rs index 3d7e23edd8..a13b9a6251 100644 --- a/crates/engine/src/cmd/fmt_revisions.rs +++ b/crates/engine/src/cmd/fmt_revisions.rs @@ -212,16 +212,30 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) { } } -/// Build the tracked paragraph-property change for a command that also changes the paragraph's +/// Builds the tracked paragraph-property change for a command that also changes the paragraph's /// text. The ordinary after-command recorder deliberately compares only paragraphs with identical -/// text, so mixed text/paragraph-format transformations call this before assigning their props. -pub(crate) fn changed_para_props(s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option>> { - if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting { - return after.fmt_change.clone(); +/// text, so mixed text/paragraph-format transformations use this before assigning their props. +/// Made once per command, before it creates revisions: it keeps the revisions as they were then +/// (all `Recorder::own` needs), instead of copying the list for every paragraph. +pub(crate) struct ParaPropTracker { + before: Vec, +} + +impl ParaPropTracker { + pub(crate) fn new(s: &Session) -> Self { + let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting; + ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } } + } + + pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option>> { + if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting { + return after.fmt_change.clone(); + } + let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None }; + r.change(before, after, ParaProps::formatting, |p| &p.fmt_change) + .map(|Replacement(change)| change) + .unwrap_or_else(|| after.fmt_change.clone()) } - let revisions_before = s.doc.revisions.clone(); - let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions_before, revisions: &mut s.doc.revisions, rid: None }; - r.change(before, after, ParaProps::formatting, |p| &p.fmt_change).map(|Replacement(change)| change).unwrap_or_else(|| after.fmt_change.clone()) } /// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs index 63160bfe10..2eba2cbec3 100644 --- a/crates/engine/src/cmd/para/tools.rs +++ b/crates/engine/src/cmd/para/tools.rs @@ -96,9 +96,16 @@ fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Ve Ok(()) } -/// The byte ranges of every manual line break in the paragraph. +/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under +/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or +/// count goes through this. +fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator + '_ { + para.text.char_indices().filter(|(i, _)| para.props_of_char(*i).del.is_none()) +} + +/// The byte ranges of every manual line break in the paragraph that is not already deleted. fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> { - para.text.char_indices().filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect() + live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect() } /// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a @@ -107,7 +114,7 @@ pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult { scope_all(s, v)?; let (story, paths) = targets(s)?; check_target_edits(s, story, &paths)?; - let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| p.text.chars().filter(|c| *c == LINE_BREAK).count()).sum(); + let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum(); if breaks > MAX_TOOL_PARAS { return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once"))); } @@ -178,11 +185,17 @@ pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// The number of leading spaces in the paragraph's text. +/// The number of leading spaces in the paragraph's text, deleted ones included (the range +/// `trimLeadingSpaces` deletes). fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize { para.text.chars().take_while(|c| *c == ' ').count() } +/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted. +fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec { + live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect() +} + /// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph. pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult { scope_all(s, v)?; @@ -205,24 +218,25 @@ pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult { check_target_edits(s, story, &paths)?; for path in paths { let Some(para) = s.doc.para(story, &path) else { continue }; + // Runs of live spaces (deleted characters are skipped, as if absent). A run at the + // paragraph's start stays; a run inside the text collapses to its last space, and one + // that reaches the paragraph's end to its first. let mut runs = Vec::new(); - let mut start = None; - for (i, c) in para.text.char_indices() { + let mut run: Vec = Vec::new(); + let mut leading = true; + for (i, c) in live_chars(para) { if c == ' ' { - start.get_or_insert(i); - } else if let Some(a) = start.take() { - // A run at the paragraph's start stays; a run inside the text collapses to one space. - if a > 0 && i - a > 1 { - runs.push((a, i - 1)); - } + run.push(i); + continue; } + if !leading && run.len() > 1 { + runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1))); + } + run.clear(); + leading = false; } - // A run that reaches the paragraph's end collapses to one space too. - if let Some(a) = start - && a > 0 - && para.len() - a > 1 - { - runs.push((a + 1, para.len())); + if !leading && run.len() > 1 { + runs.extend(run[1..].iter().map(|&a| (a, a + 1))); } apply_deletions(s, story, &path, runs)?; } @@ -235,9 +249,11 @@ pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult { scope_all(s, v)?; let (story, paths) = targets(s)?; check_target_edits(s, story, &paths)?; + let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s); for path in paths { let Some(para) = s.doc.para(story, &path) else { continue }; - let spaces = leading_spaces(para); + let live = live_leading_spaces(para); + let spaces = live.len(); if spaces == 0 { continue; } @@ -250,14 +266,14 @@ pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult { let mut after = before.clone(); after.indent_first = Some(current + represented as f32 * 72.0); after.cancel_char_indents(false, false, true); - after.fmt_change = crate::cmd::fmt_revisions::changed_para_props(s, &before, &after); + after.fmt_change = tracker.changed(s, &before, &after); { let p = s.doc.para_mut(story, &path)?; p.props = after; p.touch(); } - // A space is one byte, so the byte range is the count. - apply_deletions(s, story, &path, vec![(0, represented)])?; + // A space is one byte; deleted spaces in between stay as they are. + apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?; } sel_result(s) } @@ -268,6 +284,7 @@ pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult { scope_all(s, v)?; let (story, paths) = targets(s)?; check_target_edits(s, story, &paths)?; + let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s); for path in paths { let Some(para) = s.doc.para(story, &path) else { continue }; let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0); @@ -290,7 +307,7 @@ pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult { after.indent_first = Some(0.0); after.indent_left = Some(0.0); after.cancel_char_indents(true, false, true); - after.fmt_change = crate::cmd::fmt_revisions::changed_para_props(s, &before, &after); + after.fmt_change = tracker.changed(s, &before, &after); let p = s.doc.para_mut(story, &path)?; if n > 0 { p.insert_text(0, &" ".repeat(n), &props)?; diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs index fedd76c870..fc782ae3ab 100644 --- a/crates/engine/src/tests/para_tools.rs +++ b/crates/engine/src/tests/para_tools.rs @@ -400,6 +400,53 @@ fn deletions_are_recorded_under_track_changes() { assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); } +/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on). +fn track_delete_in_first(s: &mut Session, a: usize, b: usize) { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off }; + crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete"); +} + +fn first_indent(s: &Session) -> Option { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first +} + +#[test] +fn remove_extra_spaces_skips_tracked_deleted_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); // the second space is already deleted + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b", "the one live space survives"); +} + +#[test] +fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " x"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0)); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time"); +} + +#[test] +fn convert_breaks_leaves_a_tracked_deleted_break_alone() { + let mut s = s(); + type_breaks(&mut s, "a\nb"); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 1, 2); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1, "a deleted break is not turned into a paragraph break"); +} + #[test] fn junk_scope_is_refused() { let mut s = s(); From afc01f832816d742c9c9a2eb0e337da3e51b538b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 02:14:50 +0100 Subject: [PATCH 03/47] Paragraph tools: walk runs once for live characters; stronger tracked-change tests Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/para/tools.rs | 2 +- crates/engine/src/tests/para_tools.rs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs index 2eba2cbec3..b900b6c205 100644 --- a/crates/engine/src/cmd/para/tools.rs +++ b/crates/engine/src/cmd/para/tools.rs @@ -100,7 +100,7 @@ fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Ve /// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or /// count goes through this. fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator + '_ { - para.text.char_indices().filter(|(i, _)| para.props_of_char(*i).del.is_none()) + para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c))) } /// The byte ranges of every manual line break in the paragraph that is not already deleted. diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs index fc782ae3ab..bca60f06ec 100644 --- a/crates/engine/src/tests/para_tools.rs +++ b/crates/engine/src/tests/para_tools.rs @@ -412,6 +412,11 @@ fn first_indent(s: &Session) -> Option { s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first } +fn first_deleted(s: &Session, off: usize) -> bool { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some() +} + #[test] fn remove_extra_spaces_skips_tracked_deleted_spaces() { let mut s = s(); @@ -422,6 +427,16 @@ fn remove_extra_spaces_skips_tracked_deleted_spaces() { run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); run(&mut s, "review.acceptAll", json!({})); assert_eq!(text(&s), "a b", "the one live space survives"); + + // Three spaces, the middle one already deleted: of the two live ones the last is kept. + let mut s = self::s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted"); + assert!(!first_deleted(&s, 3), "the last live space stays"); } #[test] @@ -439,12 +454,12 @@ fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { #[test] fn convert_breaks_leaves_a_tracked_deleted_break_alone() { let mut s = s(); - type_breaks(&mut s, "a\nb"); + type_breaks(&mut s, "a\nb\nc"); run(&mut s, "review.trackChanges", json!({"value": true})); track_delete_in_first(&mut s, 1, 2); select_all(&mut s); run(&mut s, "para.convertBreaks", json!({"scope": "all"})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1, "a deleted break is not turned into a paragraph break"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break"); } #[test] From 586f85df960179e5723317aa438d7b0c7a2e74e5 Mon Sep 17 00:00:00 2001 From: Marc <184940552+marcwgls@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:26:26 +0100 Subject: [PATCH 04/47] fix(ui): the File page scrolls across its full width, with the bar at the window edge (#5282) (#5320) * fix(ui): the File page scrolls across its full width, with the bar at the right edge (#5282) Turn off horizontal auto_shrink on the File page ScrollArea so the scroll area expands across the full CentralPanel width. This positions the vertical scrollbar at the right edge of the window and allows mouse wheel scrolling over the entire panel width. * fix(ui): the File page's scroll area reaches the window edge (#5282) The side padding moves from the panel into the pages, so the scrollbar sits at the edge and the wheel also works in the rightmost 40 points. * test(ui): assert the File page's scrollbar sits at the window edge (#5282) --- crates/ui-egui/src/backstage.rs | 31 +++++++++------- crates/ui-egui/src/backstage/tests.rs | 52 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index d3fd4a4ccc..f0e25a7cbd 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -184,23 +184,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); }); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(0, 30))).show(ui, |ui| { + // The side padding sits inside the pages, so the scroll area reaches the window's edge. + let pad = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 0)); // The Print page fills the pane and scrolls its settings and its preview itself (#1156). if app.ui.backstage_page == "print" { - crate::rtl::root(ui, |ui| export_page(app, ui)); + pad.show(ui, |ui| crate::rtl::root(ui, |ui| export_page(app, ui))); return; } - egui::ScrollArea::vertical().show(ui, |ui| { - crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() { - "home" => home_page(app, ui), - "new" => new_page(app, ui), - "open" => open_page(app, ui), - "saveAs" => save_as_page(app, ui), - "info" => info_page(app, ui), - "recover" => recovery_page(app, ui), - "print" | "export" => export_page(app, ui), - "options" => crate::options::page(app, ui), - _ => home_page(app, ui), + // Full width, so the bar sits at the window's edge and the wheel works over the whole page (#5282). + egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { + pad.show(ui, |ui| { + crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() { + "home" => home_page(app, ui), + "new" => new_page(app, ui), + "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), + "info" => info_page(app, ui), + "recover" => recovery_page(app, ui), + "print" | "export" => export_page(app, ui), + "options" => crate::options::page(app, ui), + _ => home_page(app, ui), + }) }) }); }); diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 21d7ec8990..ed64c25458 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -1054,3 +1054,55 @@ fn backstage_save_as_sidebar_opens_save_as_page() { h.run_steps(4); assert_eq!(h.state().ui.backstage_page, "saveAs"); } + +/// Scrolls the File page with the wheel at `x` and returns doc0.docx's top before and after, +/// plus the rectangle of the page's vertical scrollbar. +fn file_page_wheel_at(x: f32) -> (f32, f32, egui::Rect) { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.recent = (0..12).map(|i| format!("/docs/doc{i}.docx")).collect(); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 500.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + + let bar = h + .query_all_by_role(egui::accesskit::Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the File page's vertical scrollbar"); + let before = h.get_by_label("doc0.docx").rect(); + h.event(egui::Event::PointerMoved(egui::pos2(x, 300.0))); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + (before.min.y, h.get_by_label("doc0.docx").rect().min.y, bar) +} + +/// #5282: the File page scrolls with the wheel over its right side. +#[test] +fn the_file_page_scrolls_with_the_wheel_over_its_right_side() { + let (before, after, _) = file_page_wheel_at(1300.0); + assert!(after < before - 1.0, "expected doc0.docx to scroll up: before={before}, after={after}"); +} + +/// #5282: the scroll area reaches the window's right edge, so its bar sits there and the wheel +/// works there too. +#[test] +fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() { + // The harness keeps an 8 point margin, so the window edge is at x=1432. + let (before, after, bar) = file_page_wheel_at(1428.0); + assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}"); + assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}"); +} From b7c2fe8dff253426ee1dc2e540dba4b502516b75 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:28:39 +0100 Subject: [PATCH 05/47] DOCX: a VML text box keeps its text direction (layout-flow) (#5193) (#5346) Compatibility-mode documents store text boxes as VML only. The reader ignored v:textbox's text direction, so a header text box turned 270 degrees was drawn level. It now reads it as Word for Windows does: mso-layout-flow-alt:bottom-to-top is vert270 whatever layout-flow says; otherwise layout-flow:vertical or vertical-ideographic is vert; anything else stays horizontal. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docx/src/read/story/vml.rs | 9 +++++++++ crates/docx/tests/it/text_boxes.rs | 13 ++++++++++++- docs/file-format-parity.md | 4 +++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3e3176853..1b28d2b960 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) - 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 +835,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 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-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged. +> 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193). > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index b625b8d4f0..e40d38b3a7 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -224,6 +224,15 @@ impl Reader<'_> { { let id = self.read_textbox(sc, t, rels); if let Some(part) = self.doc.parts.get_mut(&id) { + // Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is + // `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also + // `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal. + let tb_style = tb.attr("style").unwrap_or(""); + if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) { + part.body.vert = wordcraft_doc::props::TextVert::Vert270; + } else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) { + part.body.vert = wordcraft_doc::props::TextVert::Vert; + } // Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left // out keeping Word's default; a bare number is in EMUs, VML's base unit. // `o:insetmode="auto"` leaves them to the application. diff --git a/crates/docx/tests/it/text_boxes.rs b/crates/docx/tests/it/text_boxes.rs index 98144fdd33..a2ac71a6e3 100644 --- a/crates/docx/tests/it/text_boxes.rs +++ b/crates/docx/tests/it/text_boxes.rs @@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() { shape("", r#"o:insetmode="auto" inset="0,0,0,0""#), // In a group, a member's margins go with that member's text, not an empty box's before it. format!(r#"{content}"#), + // Text direction (#5193), as Word for Windows reads it: `vertical` and + // `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any + // `layout-flow` or none; `horizontal-ideographic` stays horizontal. + shape("", r#"style="layout-flow:vertical""#), + shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#), + shape("", r#"style="layout-flow:vertical-ideographic""#), + shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#), + shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#), + shape("", r#"style="layout-flow:horizontal-ideographic""#), ] .concat(); let doc = format!( @@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() { let rels = br#""#; let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); let bodies: Vec = boxes(&d).iter().map(|id| d.parts[id].body).collect(); - assert_eq!(bodies.len(), 6); + assert_eq!(bodies.len(), 12); assert_eq!(bodies[0].inset, Some([0, 0, 0, 0])); assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center)); assert_eq!(bodies[2], TextBody::default()); assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom)); assert_eq!(bodies[4].inset, None); assert_eq!(bodies[5].inset, Some([0, 0, 0, 0])); + let verts: Vec = bodies[6..].iter().map(|b| b.vert).collect(); + assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]); // Saved and read again, they stay. let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); let again: Vec = boxes(&back).iter().map(|id| back.parts[id].body).collect(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 89b32269b0..4dc5e6d178 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -131,7 +131,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd` with their `type`, `w` and `len` kept; drawn as triangles 2, 3 or 5 times the line's width long and wide, as Word does), including custom-geometry paths (#416); fills with transparency (`a:alpha`) and gradients (`a:gradFill`: up to 10 stops each with its transparency, `a:lin` angle and `scaled`, `a:path` circle/rect/shape with `fillToRect`, `tileRect`, `rotWithShape`, `flip`) read and written as stated, also through the theme's fill styles (`a:fillRef` 1–999 `a:fillStyleLst`, 1001+ `a:bgFillStyleLst`, `phClr` in the reference's colour; written as the resolved fill), outline gradients kept (drawn in their middle stop) (#1500). Drawn: linear and circular; rectangular and shape-following paths drawn circular; `tileRect`/`flip` not drawn; pattern fills drawn and saved as their foreground colour. Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | -| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read | +| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Text-box text direction is read as Word reads it: `mso-layout-flow-alt:bottom-to-top` as `vert270` whatever `layout-flow` says, else `layout-flow:vertical` or `vertical-ideographic` as `vert`, else horizontal (#5193). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | @@ -321,6 +321,8 @@ For field results, the engine parser treats a backslash in quoted arguments as e ## Revision history +> 2026-11-07 · VML text boxes: `mso-layout-flow-alt:bottom-to-top` reads as `vert270` with any `layout-flow`, and `layout-flow:vertical` or `vertical-ideographic` otherwise as `vert`, so a compatibility-mode header's turned text box is drawn turned (#5193, `it::text_boxes::vml_text_boxes_keep_their_inset_fit_and_anchor`). + > 2026-11-03 · Section `w:chapStyle` values preserve the full unsigned-byte range, including 10, while values above 255 are rejected. | 2026-10-30 | trivial | DOCX section metadata now preserves independent printer trays, section endnote suppression, chapter numbering settings, and footnote columns through native JSON and repeated save/reopen | From cd81d5da4986b4d0176c053fadd4a00f6fbf329f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 7 Nov 2026 22:28:57 -0500 Subject: [PATCH 06/47] XLSX import: index worksheet relationships by id (#5424) * fix(formats): index XLSX worksheet relationships by id * style: rustfmt --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/formats/src/sheet.rs | 21 +++++++++++++++++++++ crates/formats/src/sheet/xlsx.rs | 15 +++++++-------- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b28d2b960..f4f1c4d6a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -234,6 +234,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged. - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. +- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. 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/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 53c7fca84f..beeaa1f142 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -427,6 +427,27 @@ mod tests { const RELS: &str = r#""#; + #[test] + fn workbook_sheets_find_their_relationships_by_index() { + // Every sheet searched every relationship; 4,096 sheets over 20,000 relationships. + let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#""#)).collect(); + let wb = format!( + r#"{sheets}"# + ); + let mut rels = String::from(r#""#); + // A chart sheet sharing a worksheet's id doesn't hide it. + rels.push_str(r#""#); + for i in 0..20_000 { + rels.push_str(&format!( + r#""# + )); + } + rels.push_str(""); + let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap(); + assert_eq!(names.len(), MAX_SHEETS); + assert_eq!(names.first().map(String::as_str), Some("S0")); + } + fn xlsx(sheet1: &str) -> Vec { let wb = r#""#; let wb_rels = r#""#; diff --git a/crates/formats/src/sheet/xlsx.rs b/crates/formats/src/sheet/xlsx.rs index dffa9be7dd..cca2472119 100644 --- a/crates/formats/src/sheet/xlsx.rs +++ b/crates/formats/src/sheet/xlsx.rs @@ -83,14 +83,13 @@ fn workbook(pkg: &mut Package<'_>) -> Result { } } }); - let sheets = listed - .into_iter() - .filter_map(|(name, id)| { - // Worksheets only: chart and dialog sheets hold no cells. - let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?; - Some(SheetRef { name, path: r.target.clone() }) - }) - .collect(); + // Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first + // relationship with an id wins), not searched for every sheet. + let mut worksheets: HashMap<&str, &str> = HashMap::new(); + for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) { + worksheets.entry(r.id.as_str()).or_insert(r.target.as_str()); + } + let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect(); let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone()); Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") }) } From 6d0bd73127c773f6353a22ad34cca0cd8d23aef5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:31:23 -0600 Subject: [PATCH 07/47] Preserve paragraph text direction in DOCX (#5392) * WC6-07: preserve paragraph text direction * Resolve gaps summary merge * WC6-07: retain paragraph direction variants * WC6-07: preserve strict paragraph direction * Update lockfile for DOCX test dependency * Close paragraph direction implementation * Format paragraph direction imports --- crates/doc/src/props.rs | 3 +- crates/doc/src/props/para_props.rs | 65 ++++++++++++++++++ crates/docx/src/read/props.rs | 5 +- crates/docx/src/write/props.rs | 3 + crates/docx/tests/it/roundtrip.rs | 5 +- .../docx/tests/it/section_text_direction.rs | 68 +++++++++++++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a801081845..38feba083c 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -27,7 +27,8 @@ pub use char_props::{ }; pub use color::{Rgb, TextColor}; pub use para_props::{ - Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame, + Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop, + TextAlignment, TextFrame, }; pub use table_props::{ CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index c34d527192..a48540248e 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -26,6 +26,67 @@ impl Align { } } +/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41). +/// +/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction +/// control. These values preserve the exact OOXML direction through JSON and DOCX saves. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParagraphTextDirection { + LrTb, + LrTbV, + Tb, + TbRl, + TbRlV, + TbLr, + TbLrV, + BtLr, + TbV, + Rl, + RlV, + Lr, + LrV, +} + +impl ParagraphTextDirection { + pub fn from_ooxml(value: &str) -> Option { + match value { + "lrTb" => Some(Self::LrTb), + "lrTbV" => Some(Self::LrTbV), + "tb" => Some(Self::Tb), + "tbRl" => Some(Self::TbRl), + "tbRlV" => Some(Self::TbRlV), + "tbLr" => Some(Self::TbLr), + "tbLrV" => Some(Self::TbLrV), + "btLr" => Some(Self::BtLr), + "tbV" => Some(Self::TbV), + "rl" => Some(Self::Rl), + "rlV" => Some(Self::RlV), + "lr" => Some(Self::Lr), + "lrV" => Some(Self::LrV), + _ => None, + } + } + + pub fn ooxml(self) -> &'static str { + match self { + Self::LrTb => "lrTb", + Self::LrTbV => "lrTbV", + Self::Tb => "tb", + Self::TbRl => "tbRl", + Self::TbRlV => "tbRlV", + Self::TbLr => "tbLr", + Self::TbLrV => "tbLrV", + Self::BtLr => "btLr", + Self::TbV => "tbV", + Self::Rl => "rl", + Self::RlV => "rlV", + Self::Lr => "lr", + Self::LrV => "lrV", + } + } +} + /// Vertical alignment of characters of different sizes on a line (`w:textAlignment`, /// ECMA-376 §17.3.1.39). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -391,6 +452,9 @@ pub struct ParaProps { pub suppress_line_numbers: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bidi: Option, + /// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_direction: Option, /// Drop cap: lines to drop (0 = none) — applies to the first character(s). #[serde(skip_serializing_if = "Option::is_none")] pub drop_cap: Option, @@ -462,6 +526,7 @@ impl ParaProps { } fn overlay_script_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 4709916b7d..a3d8558bb1 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use wordcraft_doc::para::cap_alt; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef, - ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline, - VAlign, VMerge, VertAlign, + ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, + TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, @@ -338,6 +338,7 @@ impl PropCtx { "w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)), "w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)), "w:bidi" => p.bidi = Some(on_off(k)), + "w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml), "w:kinsoku" => p.kinsoku = Some(on_off(k)), "w:wordWrap" => p.word_wrap = Some(on_off(k)), "w:overflowPunct" => p.overflow_punct = Some(on_off(k)), diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 3968377df4..d22a297a97 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha if let Some(a) = p.align { w.val("w:jc", align_val(a)); } + if let Some(direction) = p.text_direction { + w.val("w:textDirection", direction.ooxml()); + } if let Some(t) = p.text_alignment { w.val("w:textAlignment", t.ooxml()); } diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 9738fd3724..d9cdc374c7 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill; use wordcraft_doc::numbering::ListKind; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{ - Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, - TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, + Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps, + TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; @@ -195,6 +195,7 @@ fn every_para_prop_round_trips() { suppress_hyphens: Some(true), suppress_line_numbers: Some(true), bidi: Some(false), + text_direction: Some(ParagraphTextDirection::TbRl), drop_cap: None, drop_cap_margin: None, drop_cap_distance: None, diff --git a/crates/docx/tests/it/section_text_direction.rs b/crates/docx/tests/it/section_text_direction.rs index c17656f40b..da44593bd1 100644 --- a/crates/docx/tests/it/section_text_direction.rs +++ b/crates/docx/tests/it/section_text_direction.rs @@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec { zw.finish().unwrap().into_inner() } +fn docx_with_styles(body: &str, styles: &str) -> Vec { + let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let root = r#""#; + let document_rels = r#""#; + let doc = format!( + r#"{body}"# + ); + for (name, value) in [ + ("_rels/.rels", root.as_bytes()), + ("word/_rels/document.xml.rels", document_rels.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/styles.xml", styles.as_bytes()), + ] { + bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + bytes.write_all(value).unwrap(); + } + bytes.finish().unwrap().into_inner() +} + fn document_xml(bytes: &[u8]) -> String { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut s = String::new(); @@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() { let plain = wordcraft_docx::read(&docx(r#"Body"#)).unwrap(); assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection")); } + +#[test] +fn paragraph_text_direction_round_trips_through_two_saves_and_json() { + use wordcraft_doc::props::ParaProps; + use wordcraft_doc::props::ParagraphTextDirection; + + let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"]; + let mut body: String = + values.iter().map(|value| format!(r#"{value}"#)).collect(); + body.push_str(r#"Inherited"#); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect(); + for (para, value) in paras.iter().zip(values) { + assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}"); + } + assert_eq!(paras[values.len()].props.text_direction, None); + let json = serde_json::to_value(¶s[0].props).unwrap(); + assert_eq!(json["textDirection"], "lrTb"); + let decoded: ParaProps = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb)); + + let first = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&first); + for value in values { + assert!(xml.contains(&format!(r#""#)), "{xml}"); + } + assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}"); + let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap(); + let twice = document_xml(&second); + assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}"); + for value in values { + assert!(twice.contains(&format!(r#""#)), "{twice}"); + } +} + +#[test] +fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() { + let styles = r#""#; + let body = r#"StyleOverride"#; + let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert_eq!(xml.matches(r#""#).count(), 2, "both paragraphs keep their style: {xml}"); + assert!(xml.contains(r#""#), "direct horizontal is explicit: {xml}"); + assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap(); + let mut styles_xml = String::new(); + z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap(); + assert!(styles_xml.contains(r#""#), "style value survives: {styles_xml}"); +} From c40719f4312a82bbdf98585f62c3ff8836ae49d7 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:34:00 +0100 Subject: [PATCH 08/47] Layout: a turned inline picture with no recorded room for the turn keeps its line to its frame, as in Word (#5448) A file that turns an inline picture but writes no wp:effectExtent (or a zero one), as other tools do, makes Word keep the picture's line to its frame and let the turn overhang it, cut at the line's top and bottom: the frame as it is in Word 2007 layout (compatibilityMode < 15), with width and height exchanged from 45 to 135 and 225 to 315 degrees in Word 2013+ layout. WordCraft always reserved the exact rotated bounds (Float::spin_pad), which is what Word's own files encode through their effect extents, so the text after such a picture sat up to 34 pt too low (docxide-pdf standalone-picture-compat12-* / compat15-angle45/135). The docx reader now marks such objects (Float::bare_turn, cleared when the object is turned again); the inline room comes from Float::line_pad, which display uses too to place the frame in it, and the line clips the picture's overhang. The writer keeps writing no turn overhang for them, so a save round-trips. Word-saved pictures are unchanged. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 5 +- crates/doc/src/para/float.rs | 23 +++++++++ crates/docx/src/read/story/drawing.rs | 5 ++ crates/docx/src/write/embed.rs | 5 +- crates/docx/src/write/story/drawing.rs | 5 +- crates/docx/tests/it/fixtures.rs | 48 +++++++++++++++++++ crates/layout/src/display/lines.rs | 13 +++++- crates/layout/src/display/objects.rs | 9 ++-- crates/layout/src/floats.rs | 3 +- crates/layout/src/para/paragraph.rs | 2 +- crates/layout/src/tests.rs | 53 +++++++++++++++++++-- crates/pdf/src/exporter.rs | 65 +++++++++++++++++--------- crates/pdf/src/tags.rs | 5 +- crates/pdf/src/tests.rs | 39 ++++++++++++++++ docs/gaps.md | 5 +- docs/layout-parity.md | 5 +- 16 files changed, 247 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4f1c4d6a1..a1a0688e05 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: 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously 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-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. - 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. @@ -832,6 +833,8 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history +> 2026-11-08 · Progress log: turned inline pictures without a recorded effect extent keep their line to the frame, as in Word. +> > 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. diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 86f81c94d1..1918a7e5a2 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -186,6 +186,11 @@ pub struct Float { /// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats). #[serde(skip_serializing_if = "is_zero_u32")] pub z: u32, + /// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as + /// files from other tools have): Word then keeps an inline one's line to its frame and lets + /// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`]. + #[serde(skip_serializing_if = "is_false")] + pub bare_turn: bool, } /// Longest alternative text kept (characters): hostile files and commands are capped. @@ -241,6 +246,7 @@ impl Float { self.rot = wordcraft_geom::normalize_degrees(s.deg); self.flip_h = s.flip_h; self.flip_v = s.flip_v; + self.bare_turn = false; } /// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a /// rotated object takes beyond its frame (zero unrotated; negative where the turned object @@ -250,6 +256,23 @@ impl Float { let (bw, bh) = self.spin().extent(w, h); ((bw - w) / 2.0, (bh - h) / 2.0) } + /// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room + /// for a shadow or glow since, it saves, and so lays out, with the turn's room again. + pub fn is_bare_turn(&self) -> bool { + self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4] + } + /// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its + /// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except + /// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135° + /// and from 225° up to 315°. + pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) { + if !self.is_bare_turn() { + return self.spin_pad(w, h); + } + let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0)); + let deg = wordcraft_geom::normalize_degrees(self.rot); + if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) } + } /// The effect extents, finite and clamped (left, top, right, bottom). pub fn effect_extent(&self) -> [f32; 4] { self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }) diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index 1481df8745..75435a22a7 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -41,6 +41,7 @@ impl Reader<'_> { } let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?; float.set_spin(xfrm_spin(content.child("w14:xfrm"))); + float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4]; let (px, py) = float.spin_pad(w, h); for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) { if *e != 0.0 { @@ -106,7 +107,11 @@ impl Reader<'_> { self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id"))); // Word's effect extent also covers a rotated object's overhang; the model keeps that // apart (it follows from the angle), so take it back out. + // Shadow or glow room the file didn't record: saving records it (and the turn's room). + let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]); if let Some(f) = obj.float_mut() { + // None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`). + f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4]; let (px, py) = f.spin_pad(w, h); for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) { // Zero adds no effects room when rotation contracts this axis. diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index cc13df58ee..19154a8790 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -499,8 +499,9 @@ impl super::Writer<'_> { } } "wp:effectExtent" => { - // Word's effect extent also covers a rotated object's overhang (its rotated bounds). - let (px, py) = float.spin_pad(w, h); + // Word's effect extent also covers a rotated object's overhang (its rotated bounds), + // unless the file it came from kept none. + let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) }; let [el, et, er, eb] = float.effect_extent(); let ext = [el + px, et + py, er + px, eb + py].map(emu); e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b")); diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 0edb1117ee..4d158ba349 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -298,8 +298,9 @@ impl Writer<'_> { } } w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]); - // Word's effect extent also covers a rotated object's overhang (its rotated bounds). - let (px, py) = float.spin_pad(iw, ih); + // Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless + // the file it came from kept none. + let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) }; let [el, et, er, eb] = float.effect_extent(); let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu); w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index cb892282f8..9f82b95308 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -4354,6 +4354,54 @@ fn zero_effects_stay_zero_rotated() { } } +/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is +/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps +/// its rotated bounds. +#[test] +fn bare_turns_are_kept_through_a_save() { + let pic = |effects: &str| { + format!( + r#"{effects}"# + ) + }; + let read = + |body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap(); + let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn; + for effects in ["", r#""#] { + let d = read(&pic(effects)); + assert!(bare(&d), "{effects:?}"); + let out = wordcraft_docx::write(&d).unwrap(); + let body = text(&unzip(&out), "word/document.xml"); + assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]); + assert!(bare(&wordcraft_docx::read(&out).unwrap())); + } + // 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side. + let d = read(&pic(r#""#)); + assert!(!bare(&d)); + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.set_spin(wordcraft_geom::Spin::new(30.0, false, false)); + assert!(!f.bare_turn, "turned here: the rotated bounds again"); + // A shadow's room added later: saved, and so laid out, with the turn's room again. + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.effect = [2.0; 4]; + assert!(!f.is_bare_turn()); + let out = wordcraft_docx::write(&d).unwrap(); + assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]); + // Made floating: saved with the turn's room, so it opens again as an ordinary turn. + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.wrap = wordcraft_doc::para::Wrap::Square; + assert!(!f.is_bare_turn()); + let out = wordcraft_docx::write(&d).unwrap(); + assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]); + assert!(!bare(&wordcraft_docx::read(&out).unwrap())); + // Only inline ones: a floating one keeps text clear of its rotated bounds as before. + let anchored = pic("").replace("", "").replace("", ""); + assert!(!bare(&read(&anchored))); +} + /// The math namespace, declared on the math element itself (the fixture root doesn't). const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#; diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index e888dedfbb..059358ad82 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -637,9 +637,20 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { } let cx = x + line.cl_left(k).unwrap_or(0.0); let obj = para.and_then(|p| p.objects.get(oi)); - let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k)); + let modern = doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT; + let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern); let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default(); match obj { + // A turn its line keeps no room for overhangs the line; Word cuts it at the line's top + // and bottom, not at its sides. + Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) + if o.frame().is_some_and(|(w, h, f)| f.is_bare_turn() && f.spin_pad(w, h).1 > f.line_pad(w, h, modern).1 + 0.01) => + { + let mut items = Vec::new(); + object_draws(o, rect, Spin::default(), alpha, opts, &mut items); + let band = Rect::new(rect.x - 4000.0, ctx.top, rect.w + 8000.0, (ctx.bottom - ctx.top).max(0.0)); + out.push(Draw::Clip { rect: band, items }); + } Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => { object_draws(o, rect, Spin::default(), alpha, opts, out) } diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index 5e0b04e6e1..89211cf41a 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -487,8 +487,9 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp /// `obj_h` above the baseline and `obj_d` below it): inside the room kept for its effects. /// A rotated one's unrotated frame, centred in that room. /// -/// `line` is the room a horizontal rule is placed in, from `cx`. -pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect { +/// `line` is the room a horizontal rule is placed in, from `cx`; `modern`: Word 2013+ layout +/// (see [`wordcraft_doc::para::Float::line_pad`]). +pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32, modern: bool) -> Rect { let frame = obj.and_then(InlineObject::frame); let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); // Lowered further than its height, it is drawn with its top on the baseline (Word). @@ -503,9 +504,9 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h); } let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; - // The room is the rotated bounds of the frame: undo the rotation. + // The room is what the line kept for the turned frame: undo that. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); - let (bw, _) = float.spin().extent(w, h); + let bw = w + 2.0 * float.line_pad(w, h, modern).0; let k = if bw > 0.0 { room.w / bw } else { 1.0 }; let (fw, fh) = (w * k, h * k); Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh) diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 5135278e14..8ef098a44a 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -751,7 +751,8 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec= wordcraft_doc::COMPAT_MODE_CURRENT; + display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern) }; let (mut behind, mut front) = (Vec::new(), Vec::new()); let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 65aab68342..601e34bb91 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -397,7 +397,7 @@ impl<'a> Shaped<'a> { // The line makes room for effects (shadows) around the picture too, and // for a rotated one's bounds, as Word does. let [el, et, er, eb] = float.effect_extent(); - let (px, py) = float.spin_pad(w * s, h * s); + let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT); let tall = h * s + et + eb + 2.0 * py; // 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 diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ef3e5368b9..38c145acca 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5593,7 +5593,7 @@ fn inline_picture_keeps_room_for_its_effects() { assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents"); // Drawn inside that room. let obj = picture(100.0, 50.0, shadow); - let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv); + let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true); assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0)); } @@ -5620,22 +5620,67 @@ fn an_inline_picture_moves_with_its_runs_position() { let (c, line, _) = line_of(-30.0); assert_eq!((c.obj_h, c.obj_d), (0.0, 30.0)); 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); + 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, true); assert_eq!((r.y, r.h), (100.0, 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); + 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, true); 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)); - 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); + 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, true); assert_eq!((r.y, r.h), (64.0, 30.0)); } +/// A turned inline picture whose file kept no room for the turn: Word keeps its line to the frame +/// (Word 2007 layout) or to the frame with width and height exchanged near a quarter turn (Word +/// 2013+), and the picture overhangs it. One with room recorded takes its rotated bounds. +#[test] +fn bare_turned_inline_picture_keeps_its_frame() { + let room = |rot: f32, bare_turn: bool, compat: u32| { + let mut d = Document::from_text(""); + d.settings.compat_mode = compat; + let float = wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() }; + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.insert_object(0, picture(120.0, 60.0, float), &Default::default()).unwrap(); + } + let l = lay(&d); + 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(); + // Drawn unscaled, centred in that room. + let obj = picture(120.0, 60.0, wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() }); + let r = display::inline_rect(Some(&obj), 0.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, compat >= 15); + assert_eq!((r.w, r.h), (120.0, 60.0), "{rot}° bare {bare_turn} compat {compat}"); + assert_eq!((r.x + r.w / 2.0, r.y + r.h / 2.0), (c.adv / 2.0, 200.0 - c.obj_h / 2.0)); + // Only a bare turn that overhangs its line is cut, at the line's top and bottom. + let overhangs = + bare_turn && (c.obj_h as f64) < 120.0 * (rot as f64).to_radians().sin().abs() + 60.0 * (rot as f64).to_radians().cos().abs() - 0.1; + let clips: Vec = display::page_display(&d, &l.pages[0], &Default::default()) + .iter() + .filter_map(|i| if let display::Draw::Clip { rect, .. } = i { Some(*rect) } else { None }) + .collect(); + assert_eq!(clips.len(), usize::from(overhangs), "{rot}° bare {bare_turn} compat {compat}: {clips:?}"); + if let Some(band) = clips.first() { + assert!(band.h >= c.obj_h && band.w > 4000.0, "{band:?}"); + } + (c.adv.round(), c.obj_h.round()) + }; + assert_eq!(room(90.0, true, 12), (120.0, 60.0)); + assert_eq!(room(45.0, true, 12), (120.0, 60.0)); + assert_eq!(room(180.0, true, 12), (120.0, 60.0)); + assert_eq!(room(90.0, true, 15), (60.0, 120.0)); + assert_eq!(room(45.0, true, 15), (60.0, 120.0)); + assert_eq!(room(135.0, true, 15), (120.0, 60.0)); + assert_eq!(room(270.0, true, 15), (60.0, 120.0)); + // Room recorded (Word's own files, WordCraft's turns): the rotated bounds, in any layout. + assert_eq!(room(90.0, false, 12), (60.0, 120.0)); + assert_eq!(room(45.0, false, 15), (127.0, 127.0)); +} + /// Tops of the body text lines drawn on page one, in order. fn line_tops(l: &DocLayout) -> Vec { l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect() diff --git a/crates/pdf/src/exporter.rs b/crates/pdf/src/exporter.rs index 9ce20c8981..67737b3394 100644 --- a/crates/pdf/src/exporter.rs +++ b/crates/pdf/src/exporter.rs @@ -166,6 +166,15 @@ impl Exporter<'_> { } s.pop(); } + // A turned picture cut at its line's top and bottom. + Draw::Clip { rect, items } => { + let Some(clip) = rect_path(rect) else { return }; + s.push_clip_path(&clip, &FillRule::NonZero); + for it in items { + self.rotated_pictures(s, it); + } + s.pop(); + } Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| { @@ -177,6 +186,40 @@ impl Exporter<'_> { } } + /// One draw of a paragraph's line, tagged for what it is: text as the paragraph's content + /// (structure entry `idx`), pictures and figures on their own, anything else an artifact. + fn line_item(&mut self, s: &mut Surface, d: &Draw, idx: usize) { + match d { + Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // A picture marked decorative: an artifact screen readers skip. + Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), + // A rotated inline picture, or a rotated group holding pictures: turned as a + // whole, each picture tagged on its own (see `rotated_pictures`). + Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), + // A clip (a turned picture cut at its line, an exact-height cell's lines, a canvas's + // members): each draw in it tagged as it would be outside it. + Draw::Clip { rect, items } => { + let Some(clip) = rect_path(rect) else { return }; + s.push_clip_path(&clip, &FillRule::NonZero); + for it in items { + self.line_item(s, it, idx); + } + s.pop(); + } + // A turned text box's line: still the paragraph's text. A rotated shape + // without text falls to the artifacts below. + Draw::Rotated { items, .. } if items.iter().any(holds_text) => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. + Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { + for d in inner { + me.draw(s, d); + } + }), + _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + } + } + pub(crate) fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) { if let Some(c) = self.doc.settings.page_color && let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h)) @@ -221,27 +264,7 @@ impl Exporter<'_> { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); for d in &draws { - match d { - Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), - // A picture marked decorative: an artifact screen readers skip. - Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), - // A rotated inline picture, or a rotated group holding pictures: turned as a - // whole, each picture tagged on its own (see `rotated_pictures`). - Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), - // A turned text box's line: still the paragraph's text. A rotated shape - // without text falls to the artifacts below. - Draw::Rotated { items, .. } if items.iter().any(holds_text) => { - self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)) - } - // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. - Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { - for d in inner { - me.draw(s, d); - } - }), - _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - } + self.line_item(s, d, idx); } } Placed::Image { story, path, off, .. } => { diff --git a/crates/pdf/src/tags.rs b/crates/pdf/src/tags.rs index c8a7254fe4..b9ca50cd6c 100644 --- a/crates/pdf/src/tags.rs +++ b/crates/pdf/src/tags.rs @@ -5,11 +5,12 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } -/// A picture, or a rotation holding one at any depth (a rotated group's turned members). +/// A picture, or a rotation or clip holding one at any depth (a rotated group's turned members, a +/// turned picture cut at its line). pub(crate) fn holds_picture(d: &Draw) -> bool { match d { Draw::Image { .. } => true, - Draw::Rotated { items, .. } => items.iter().any(holds_picture), + Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture), _ => false, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5a23040367..c155cddee9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1190,6 +1190,8 @@ fn decorative_pictures_are_artifacts_not_figures() { let cases = [ ("inline", Float::default()), ("rotated inline", Float { rot: 30.0, ..Default::default() }), + // Turned with no room kept for it: drawn cut at its line (a clip), still tagged. + ("bare turned inline", Float { rot: 45.0, bare_turn: true, ..Default::default() }), ("floating", floating.clone()), ("rotated floating", Float { rot: 30.0, ..floating }), ]; @@ -1202,6 +1204,43 @@ fn decorative_pictures_are_artifacts_not_figures() { } } +/// A row of exact height cuts its cell's lines (one clip around them): text and a picture in +/// it are tagged as they are in a row that fits, the text as the paragraph's, the picture as a +/// Figure with its alt text. +#[test] +fn a_clipped_cell_tags_its_text_and_picture() { + let raw = |exact: bool| { + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let mut p = Paragraph::with_text("Cell text ", CharProps::default()); + let n = p.len(); + let obj = InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: "A red square".into(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + fx: Default::default(), + }; + p.insert_object(n, obj, &CharProps::default()).unwrap(); + let mut t = Table::new(1, 1, 300.0); + t.rows[0].cells[0].blocks = vec![para_block(p)]; + if exact { + t.rows[0].props.height = Some(30.0); + t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact; + } + d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))]; + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() + }; + let (fits, cut) = (raw(false), raw(true)); + assert!(cut.contains("/Alt(A red square)"), "the picture in the cut cell is a Figure with its alt text"); + // The same marked content either way: the cut cell's text is not dropped as an artifact. + assert_eq!(cut.matches("/MCID").count(), fits.matches("/MCID").count()); + assert_eq!(cut.matches("/S/P").count(), fits.matches("/S/P").count()); +} + /// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the /// other stays a Figure with its alt text. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 3526d9c0ca..f1f5c6ff34 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,7 +2,7 @@ > **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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); 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: 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. @@ -91,7 +91,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | -| 10 | **Objects**: editing text in a freely rotated text box (its text is drawn turned; free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), picking several Drawing Canvas members at once and dragging one out of its canvas (the canvas landed, #344; its members are picked, moved, resized and formatted on their own, #2637) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: editing text in a freely rotated text box (its text is drawn turned; free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), picking several Drawing Canvas members at once and dragging one out of its canvas (the canvas landed, #344; its members are picked, moved, resized and formatted on their own, #2637); a turned inline picture whose file keeps no room for the turn keeps its line to its frame and is cut at the line, as in Word (landed) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); its dialogs, tables and galleries read right to left too; open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | @@ -191,6 +191,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open | | 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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9b61aa351f..2a5694e94e 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-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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously 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, @@ -76,7 +76,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | | Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | -| Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box turns with it (on screen, PNG and PDF; upside down when the box is flipped across, never mirrored, as Word draws it); clicks and the caret in it still go by its upright text | 1–2 | +| Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds — unless the file keeps no room for the turn (no or a zero `wp:effectExtent`): then an inline one's line keeps to its frame, exchanged in width and height from 45° to 135° and 225° to 315° in Word 2013+ layout, and the picture overhangs it, cut at the line's top and bottom, as in Word; hit testing on the turned shape. Text in a rotated text box turns with it (on screen, PNG and PDF; upside down when the box is flipped across, never mirrored, as Word draws it); clicks and the caret in it still go by its upright text | 1–2 | | Connector lookup cost | 🟡 | One temporary layout-object index per reroute replaces repeated page scans. Repeated positions retain page-hint preference, first-match order and first-page fallback; same-page glue and free/unresolved endpoints are unchanged. Synthetic lookup equivalence, geometry and 8,000-page scaling regressions; model object access and obstacle routing remain separate, no new native Word comparison | #3631 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | @@ -300,6 +300,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word | | 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`). | From 7152143e456655c25ae827171fcd929d02393bd9 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:36:10 +0100 Subject: [PATCH 09/47] Layout: a line can break after a hyphen before a slash, like Word (#3408) (#5348) Word wraps "Dataprotection-/Secrecyviolation" as "Dataprotection-" / "/Secrecyviolation". UAX #14 (LB13) forbids a break before the slash, so the compound had no break opportunity: lines moved it whole and auto-fit table columns widened to fit it, taking width from their neighbours. Add the break after U+002D when a slash and a letter follow, next to the existing break before a digit (#2712); Word keeps "fitness-/1589" whole, so a slash before a digit gains no break. Always sort the added breaks. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/para/paragraph.rs | 24 ++++++++----- crates/layout/src/tests.rs | 55 +++++++++++++++++++++++++++++ docs/gaps.md | 2 ++ docs/layout-parity.md | 2 ++ 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a1a0688e05..5775bb57cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -239,6 +239,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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. +- 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. @@ -1346,3 +1347,4 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-10-29 · Port #3915 to split layout modules: ordinary LTR tab groups follow paragraph alignment. Positional tabs, lists, RTL and display math retain their alignment paths. Source port only; no test execution or new native measurements. > 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements. +| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) | diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 601e34bb91..f844e541ab 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -837,21 +837,27 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec } // Word breaks after a hyphen even when a digit follows ("2016-" / "2022"), where UAX #14 - // keeps a hyphen and a number together (LB25): add those positions back. Only U+002D before - // ASCII digits is added here; a non-breaking hyphen (U+2011) and a minus sign (U+2212) stay - // unbreakable, and the other dashes keep their ordinary UAX #14 breaks. + // keeps a hyphen and a number together (LB25), and when a slash and a letter follow + // ("Dataprotection-" / "/Secrecyviolation", #3408), where LB13 forbids a break before the + // slash: add those positions back. Word keeps "fitness-/1589" whole, so a slash before a digit + // gains no break. Only U+002D is added here; a non-breaking hyphen (U+2011) and a minus sign + // (U+2212) stay unbreakable, and the other dashes keep their ordinary UAX #14 breaks. let hyphen_breaks: Vec = text .match_indices('-') .filter_map(|(pos, _)| { let i = pos + 1; - text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i) + let mut next = text.get(i..).unwrap_or_default().chars(); + match next.next() { + Some(n) if n.is_ascii_digit() => Some(i), + Some('/') => next.next().is_some_and(char::is_alphabetic).then_some(i), + _ => None, + } }) .collect(); - if !hyphen_breaks.is_empty() { - found.extend(hyphen_breaks); - found.sort_unstable(); - found.dedup(); - } + found.extend(hyphen_breaks); + // The breaks added above come after UAX #14's: put them back in order. + found.sort_unstable(); + found.dedup(); // Myanmar has no spaces between words: it wraps between syllables. if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) { found.extend(wordcraft_doc::cluster::myanmar_breaks(text)); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 38c145acca..cc822cb1c0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13550,6 +13550,61 @@ fn a_non_breaking_hyphen_and_a_minus_sign_stay_unbreakable() { } } +/// #3408: Word breaks a compound right after a hyphen that a slash follows ("Dataprotection-" / +/// "/Secrecyviolation"), where UAX #14 forbids a break before the slash. The first width that +/// wraps must break after the hyphen, not move the compound whole, and the slash stays with its +/// word. Table auto-fit reads the same break, so such a column no longer widens to the compound. +/// Word keeps "fitness-/1589" whole: a slash before a digit gains no break. +#[test] +fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() { + let digits = "Deed fitness-/1589"; + let mut wrapped = false; + for m in 100..=540 { + let mut d = Document::from_text(digits); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec<&str> = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, l0, l1, .. } = it { Some(¶.lines[*l0..*l1]) } else { None }) + .flatten() + .map(|line| &digits[line.start..line.stop]) + .collect(); + if lines.len() >= 2 { + assert_eq!(lines, ["Deed ", "fitness-/1589"], "the first wrap breaks at the space, not after the hyphen"); + wrapped = true; + break; + } + } + assert!(wrapped, "no width in the sweep wrapped the digit case"); + + let text = "Deed Dataprotection-/Secrecyviolation"; + for m in 100..=540 { + let mut d = Document::from_text(text); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| { + if let Placed::Lines { para, l0, l1, .. } = it { + Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::>()) + } else { + None + } + }) + .flatten() + .collect(); + if lines.len() < 2 { + continue; // still fits on one line at this width + } + assert_eq!(lines[0], "Deed Dataprotection-", "the first wrap breaks after the hyphen: {lines:?}"); + assert_eq!(lines[1], "/Secrecyviolation", "the slash starts the next line with its word: {lines:?}"); + return; + } + panic!("no width in the sweep wrapped the paragraph"); +} + #[test] fn layout_cache_invalidates_on_changed_filename() { let mut doc = Document::new(); diff --git a/docs/gaps.md b/docs/gaps.md index f1f5c6ff34..7527aa05d3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -85,6 +85,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 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) | | 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). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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). | 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) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -232,6 +233,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | | 2026-11-03 | trivial | #8 (CP4.3): File › Options › Language lists Your languages and Add a language… (searchable, sorted by name) replaces the Language packs table; the Dictionaries folder and the Dictionary files list moved from Proofing | | 2026-10-28 | trivial | #4: fonts the document uses that aren't installed are flagged in the font box and status bar (metric-compatible substitutes and symbol fonts aren't); a missing font is drawn in an installed metric twin before other substitutes (Arial for Helvetica on Windows), so pages break as in the document's own font wherever such a twin is installed | +| 2026-11-03 | trivial | #4: a hyphen before a slash and a letter is a line break opportunity, a hyphen before a slash and a digit stays whole (#3408) | | 2026-11-03 | trivial | #8: language-packs-2 published and pinned, 75 packs; Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto available as Language packs | | 2026-11-02 | trivial | #8 (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available as Language packs; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded; no ranked gap changed | | 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. | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2a5694e94e..4885093b76 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -25,6 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference)) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — | @@ -320,6 +321,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded | | 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before | | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | +| 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From b9936609dc1d8a47f92f655ff54d032e9d3983e4 Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:38:32 +0700 Subject: [PATCH 10/47] Output counts locked SEQ fields, and not tracked-deleted ones, as Word does (#5594) * Count locked SEQ fields and not tracked-deleted ones in output Exporting, Word counts a locked SEQ, its \r too, though the field keeps its result, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows). Output counted it the other way round: a locked SEQ was skipped and a deleted one counted. Co-Authored-By: Claude Opus 5.5 * Docs: locked and tracked-deleted SEQ fields in layout parity Co-Authored-By: Claude Opus 5.5 * count_seq walks the deleted ranges with one cursor The runs come in order and the objects by offset, so a forward cursor replaces the per-field scan of every deleted range. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- crates/engine/src/cmd/references/fields.rs | 17 +++++++--- crates/engine/src/cmd/references/tests.rs | 39 ++++++++++++++++++++++ docs/layout-parity.md | 3 +- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/references/fields.rs b/crates/engine/src/cmd/references/fields.rs index 238cf553b3..86d81ce49e 100644 --- a/crates/engine/src/cmd/references/fields.rs +++ b/crates/engine/src/cmd/references/fields.rs @@ -102,7 +102,9 @@ struct SeqCount { /// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there /// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after -/// the anchor in its paragraph and after those before it (measured in Word for Windows). +/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked +/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its +/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting). fn seq_results(doc: &wordcraft_doc::Document) -> Vec { let mut count = SeqCount::default(); for path in doc.para_paths(StoryRef::Body) { @@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec { /// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it. fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) { - for (k, o) in p.objects.iter().enumerate() { + // The runs come in order and the objects by offset, so one cursor walks the deleted ranges. + let mut deleted = p.deleted_ranges().into_iter().peekable(); + for (k, (off, o)) in p.objects_with_offsets().enumerate() { + while deleted.next_if(|r| r.end <= off).is_some() {} + let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off)); for id in o.text_boxes() { if count.boxes.insert(id) { let part = StoryRef::Part(id); @@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa } } } - let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let InlineObject::Field { instr, locked, .. } = o else { continue }; let f = FieldCode::parse(instr); - if f.name != "SEQ" { + if f.name != "SEQ" || in_deletion { continue; } let Some(id) = f.args.first() else { continue }; @@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa } else if !f.has('c') { n.0 = n.0.saturating_add(1); } + if *locked { + continue; + } let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default(); let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }; count.out.push((story, path.clone(), k, r)); diff --git a/crates/engine/src/cmd/references/tests.rs b/crates/engine/src/cmd/references/tests.rs index 63b624f387..b2838cd9f9 100644 --- a/crates/engine/src/cmd/references/tests.rs +++ b/crates/engine/src/cmd/references/tests.rs @@ -793,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() { assert_eq!(results, expected); } +/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ +/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows, +/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6). +#[test] +fn output_counts_locked_seq_fields_and_not_deleted_ones() { + let mut s = Session::new(wordcraft_doc::Document::from_text("")); + let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"]; + for instr in fields_in { + s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let top = wordcraft_doc::Path::top(0); + let para = s.doc.para_mut(StoryRef::Body, &top).unwrap(); + for (k, o) in para.objects.iter_mut().enumerate() { + if let InlineObject::Field { result, locked, .. } = o { + *locked = matches!(k, 4 | 6); + *result = if *locked { "7" } else { "9" }.into(); + } + } + para.touch(); + // The second Del field is a tracked deletion. + let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1]; + let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off }; + s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap(); + let out = output_fields(&s.doc).expect("the stale results change"); + let shown: Vec = out + .para(StoryRef::Body, &top) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect(); + assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]); + assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results"); +} + /// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error /// (measured in Word for Windows: "Error! Reference source not found."); the editor and the /// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4885093b76..9f0561083b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -95,7 +95,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | -| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`) | — | +| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`, `cmd::references::tests::output_counts_locked_seq_fields_and_not_deleted_ones`) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Fields: `\* DollarText`, `\* Hex` and `\* ArabicDash` | 🟡 | `\* DollarText` writes lowercase cardinal text, "and", and the first two decimals over 100 (`{ = 9.20 + 5.35 \* DollarText }` shows "fourteen and 55/100"), `\* Hex` uppercase hexadecimal digits (458 shows "1CA"), and `\* ArabicDash` the number between hyphens (`{ PAGE \* ArabicDash }` shows "- 31 -"), in table formulas and in the page-number, SEQ, PAGEREF, NUMWORDS, NUMCHARS and REVNUM fields; a capitalization switch applies after them (#4226). A result past 999,999 in cardinal or ordinal text, or past 999,999.99 in dollar text, keeps its digits, and so does a negative result or a fractional Hex or ArabicDash result. `layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`, `engine::cmd::table_formula::tests::dollar_text_and_hex_switches_write_the_result`, `engine::tests::table_formula_results_take_dollar_text_and_hex`; not compared with Word | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -314,6 +314,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-31 | trivial | A floating object anchored right after a page break inside a paragraph, before text, is drawn on the new page, as in Word; only a cover page's anchors, with nothing taking room after them, stay on the page before (#4262) | | 2026-10-31 | trivial | On a later page of a long paragraph, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page; one whose own wrap pushes its line on stays with the text wrapping around it (#4250, `layout::tests::a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page` and the two after it) | | 2026-10-31 | trivial | A floating object anchored in a line of a long paragraph on a later page is drawn on that page, positioned from where the paragraph continues, as in Word; a shape dropped on such a page stays there (#4250) | +| 2026-11-04 | trivial | Output counts a locked SEQ and not one in a tracked deletion, as Word does; readiness estimates unchanged | | 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box | | 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged | | 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged | From d3fcf10bcc4ec8fc2d48258d991774eab95e1eb3 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:48:48 +0100 Subject: [PATCH 11/47] Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) (#5347) * Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) A paragraph with a hidden mark and no plain text was a quiet mark: laid out with no room, its drawing kept. An in-line picture adds no plain text, so a hidden-mark paragraph holding only a picture qualified, and the text after it (the next continuous section in the report) was drawn over the picture. Such a paragraph, or one holding a note reference mark, now keeps its line; one with only floating objects stays quiet. Co-Authored-By: Claude Opus 5.5 * Layout: a legacy check box in a hidden closing mark keeps its room too (#5288) A FORMCHECKBOX field has an empty result, so a hidden-mark paragraph holding only a check box still counted as quiet and the next continuous section was drawn over the box. Fields now count as visible content when the paragraph layout shows them as something other than their result: their code, a legacy check box, or a drop-down's entry (the same test as the layout's `field`). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/pass.rs | 36 ++++++++++++++----- crates/layout/src/tests.rs | 73 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5775bb57cf..7bff0e3c34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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. - 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) +- 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288) - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. @@ -907,6 +908,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) | | 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | +| 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index 2f0513019a..fa09783f3d 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use super::cache::{LayoutCache, Totals, env_hash, hash_of}; use super::ctx::{Ctx, Pass, with_pct_base}; -use super::floats::{EarlyFloats, WrapArea, early_covered}; +use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects}; use super::flow::{OutlinePass, place_outline_para, place_para}; use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side}; use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes}; @@ -10,9 +10,10 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom}; use super::placed::Placed; use super::table_flow::place_table; use super::{ - Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection, - ViewMode, balloons, para, vertical, + Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart, + StoryRef, TextDirection, ViewMode, balloons, para, vertical, }; +use wordcraft_doc::legacy_form::LegacyForm; /// How the view lays the document out: `web` for the continuous views, `reflow` for those and /// the reading view, which both ignore the sections' page geometry. @@ -127,18 +128,35 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty()) }); if !opts.show_hidden { - quiet.extend( - doc.body - .iter() - .enumerate() - .filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)), - ); + quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| { + b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i) + })); } quiet.sort_unstable(); quiet.dedup(); (stacked, quiet) } +/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other +/// than its result, is visible content with no plain text, so its line keeps its room even when +/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one +/// in hidden or deleted text). +fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool { + let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body)); + // As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry. + let shown_field = |o: &InlineObject| match o { + InlineObject::Field { code, .. } => { + *code != opts.field_codes + || o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty())) + } + _ => false, + }; + p.objects + .iter() + .enumerate() + .any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o))) +} + /// The page the continuous views lay out on: the first section's, as wide as the window (and as /// high in the reading view), with small margins and one column. fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc822cb1c0..29114735d0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -10330,6 +10330,79 @@ fn an_empty_continuous_section_mark_takes_no_room() { assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}"); } +/// A hidden mark closing a section before a continuous one, holding only an in-line picture: +/// the picture keeps its line, so the next section starts below it, not over it (#5288). A +/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden +/// text. +#[test] +fn a_hidden_mark_with_an_inline_picture_keeps_its_room() { + use wordcraft_doc::para::{Float, ShapeKind}; + use wordcraft_doc::section::{SectionProps, SectionStart}; + // Where Pb starts, and the bottom of the closing mark's picture (none: no picture). + let tops = |float: Option, run: CharProps| { + let mut d = Document::from_text("Pa\n\nPb"); + let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + closer.mark.hidden = Some(true); + closer.section = Some(Box::new(SectionProps::default())); + d.last_section.start = SectionStart::Continuous; + if let Some(float) = float { + let shape = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + effects: Default::default(), + freeform: None, + extra: Default::default(), + }; + d.insert_object(&Pos::body(1, 0), shape, &run).unwrap(); + } + let l = lay(&d); + let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom()); + (l.caret(&Pos::body(2, 0)).unwrap(), bottom) + }; + let wrapped = |wrap| Some(Float { wrap, ..Default::default() }); + let (empty, _) = tops(None, CharProps::default()); + let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default()); + let bottom = bottom.unwrap(); + assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}"); + assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}"); + assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}"); + let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default()); + assert!(drawn.is_some(), "the floating picture is drawn"); + assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}"); + let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default()); + assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}"); + let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() }); + assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}"); +} + +/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result): +/// the box is drawn with a line's room, so the next section starts below it (#5288). +#[test] +fn a_hidden_mark_with_a_check_box_keeps_its_room() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let top = |boxed: bool| { + let mut d = Document::from_text("Pa\n\nPb"); + let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + closer.mark.hidden = Some(true); + closer.section = Some(Box::new(SectionProps::default())); + d.last_section.start = SectionStart::Continuous; + if boxed { + let form = r#""#.to_string(); + let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form }; + d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap(); + } + lay(&d).caret(&Pos::body(2, 0)).unwrap() + }; + let (empty, boxed) = (top(false), top(true)); + assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}"); +} + /// Empty marks closing continuous sections one after another each keep their line while it /// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text /// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End": diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9f0561083b..f1833ce0b6 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -91,7 +91,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326); an n-ary operator, delimiter, accent or group character the math font lacks is drawn from a fallback font instead of a missing-glyph box (#1273); an equation-array row without `&` is centred among aligned rows, as in Word (#2617) | — | | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | -| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - | +| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. Not observed in Word: when such a paragraph closes a section before a continuous one and holds only an in-line picture (or a note reference, or a legacy check box), the picture keeps its room and the next section starts below it instead of over it (`a_hidden_mark_with_an_inline_picture_keeps_its_room`, #5288). A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | @@ -323,6 +323,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before | | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | +| 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From efd476cbc71365976070fb53797870fa0b066fac Mon Sep 17 00:00:00 2001 From: RatkoR Date: Sun, 8 Nov 2026 04:51:18 +0100 Subject: [PATCH 12/47] #5197: the ruler shows the spaces between text columns (#5261) In a section of several text columns the horizontal ruler was one white strip from margin to margin. The space between each two columns is now shaded like the margins, so the ruler shows where each column ends and the next starts. The edges of a space can be dragged, through layout.columns, one undo step per drag, to change the spacing. Equal columns stay equal, so every space changes with it, as in Word for Mac. Between unequal columns only the column at that edge and the space change. As in Word for Mac, the middle of a space has no grip of its own. Columns stay at least a quarter inch wide and spaces 0-300 pt, as the Columns dialog allows, snapped to 1/16 inch. Right-to-left sections are read from the right edge. Outside the main text, outside Print Layout and in a table the ruler is unchanged. A selection across sections shows no spaces, since a drag would set the columns of each section it touches; nor does a page the caret isn't on. Columns from a file that the command wouldn't take back (over 12, a column under a quarter inch, a space over 300 pt, wider than the text) are shaded but not dragged. A tab stop's marker keeps its own drag where it sits on an edge. The indent markers still follow the page rather than the caret's column; that is #2196 (PR #2215). Fixes #5197. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/column_ruler.rs | 343 ++++++++++++++++++++++ crates/ui-egui/src/canvas/rulers.rs | 10 + docs/gaps.md | 2 + docs/ui-parity.md | 4 +- 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/canvas/column_ruler.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7bff0e3c34..3e49fab3a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -365,6 +365,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | | 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) | | 2026-11-01 | Markdown export: a list inside a block quote stays in the quote (`> - item`), its continuation lines included; it was read as quoted list paragraphs but written without `>`, so the list moved out of the quote on every save | +| 2026-11-01 | Rulers: in a section of several text columns the horizontal ruler shades the space between each two columns like the margins (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge of a space changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) | | 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | @@ -944,6 +945,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | +| 2026-11-01 | trivial | Progress log: the ruler shows the spaces between text columns (#5197) | | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c3ed1a1db9..92aa25890b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,7 @@ use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; mod balloons; +mod column_ruler; mod context_menu; #[cfg(test)] pub(crate) use context_menu::issue_heading; diff --git a/crates/ui-egui/src/canvas/column_ruler.rs b/crates/ui-egui/src/canvas/column_ruler.rs new file mode 100644 index 0000000000..76e35a2636 --- /dev/null +++ b/crates/ui-egui/src/canvas/column_ruler.rs @@ -0,0 +1,343 @@ +//! The caret section's text columns on the horizontal ruler (#5197): the space between each two +//! columns is shaded like the margins, and its edges are dragged to change the columns' widths +//! and spacing, through `layout.columns`. As in Word for Mac, the space has no grip of its own +//! in the middle: only its edges drag. + +use serde_json::Value; +use wordcraft_doc::section::SectionProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{DocLayout, Rect, Sense, Tokens, Ui, WordApp, json, pos2}; + +/// Which edge of the space between two columns a drag grabs, in reading order: the end of the +/// column before it or the start of the column after it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum GapGrip { + End, + Start, +} + +/// The narrowest column a drag leaves: a quarter inch, as the Columns dialog allows. +const MIN_COLUMN: f32 = 18.0; + +/// The widest space a drag leaves between two columns, as the Columns dialog allows. +const MAX_SPACE: f32 = 300.0; + +/// The caret section's columns on a page, for the ruler. +pub(super) struct ColumnGaps { + /// The caret's section. + sect: SectionProps, + /// The page x of the section's text area's left edge. + origin: f32, + /// Each space between two neighbouring columns in reading order: its (left, right) page x. + gaps: Vec<(f32, f32)>, +} + +/// The spaces between the caret section's columns on page `pi`: none outside the main text or +/// Print Layout (where text runs in one column), in a table (whose own markers take the ruler), +/// in a section of one column, for a selection across sections (a drag would set the columns of +/// each) or when the caret isn't on that page (the ruler stays put while the whole document is +/// selected). +pub(super) fn column_gaps(app: &WordApp, layout: &DocLayout, pi: usize) -> Option { + let sel = &app.session.sel; + if sel.focus.story != StoryRef::Body || sel.anchor.story != StoryRef::Body { + return None; + } + if app.session.view.mode != wordcraft_layout::ViewMode::Print || app.session.view.read_mode { + return None; + } + if sel.focus.path.cell().is_some() || crate::table_ruler::text_area(app, layout, pi).is_some() { + return None; + } + let doc = &app.session.doc; + let section = |p: &Pos| doc.section_index_of(p.path.0.first().copied().unwrap_or(0) as usize); + if section(&sel.anchor) != section(&sel.focus) { + return None; + } + if layout.caret_on(&sel.focus, pi).map(|c| c.page) != Some(pi) { + return None; + } + let sect = wordcraft_engine::cmd::page::sect(&app.session); + if sect.columns.count <= 1 { + return None; + } + let page = layout.pages.get(pi)?; + // A continuous section can change the margins on the page: its columns start at its own + // margin, moved as the page's text area is (on an even page of a mirrored layout, #2914). + let sections = app.session.doc.sections(); + let (_, page_sect) = sections.get(page.section)?; + let origin = page.body.x + sect.margin_left + sect.gutter - page_sect.margin_left - page_sect.gutter; + let gaps = gaps_of(§).into_iter().map(|(l, r)| (origin + l, origin + r)).collect(); + Some(ColumnGaps { sect, origin, gaps }) +} + +/// Each space between two neighbouring columns of `s` in reading order, as (left, right) points +/// from the text area's left edge. +fn gaps_of(s: &SectionProps) -> Vec<(f32, f32)> { + s.column_boxes() + .windows(2) + .filter_map(|w| match w { + // Leftwards in a right-to-left section. + [(ax, aw), (bx, bw)] => Some(if bx >= ax { (ax + aw, *bx) } else { (bx + bw, *ax) }), + _ => None, + }) + .collect() +} + +/// Shades the spaces between the columns on the ruler `bar`, over the white text area. +pub(super) fn draw(painter: &egui::Painter, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, t: &Tokens) { + for (l, r) in &cg.gaps { + let (sl, sr) = (x0 + l * scale, x0 + r * scale); + if sr > sl { + painter.rect_filled(Rect::from_min_max(pos2(sl, bar.min.y), pos2(sr, bar.max.y)), 0.0, t.ruler_margin); + } + } +} + +/// The drag zones of the spaces between the columns: each of their edges, none over a tab +/// stop's marker (`tabs`), which keeps its own drag. Registered after the tab stops (whose +/// set-a-stop zone spans the text area) and before the indent markers, which keep the pointer +/// where they overlap. Columns the command wouldn't take back get no zones. +pub(super) fn handles(app: &mut WordApp, ui: &Ui, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, tabs: &[Rect]) { + if !draggable(&cg.sect) { + return; + } + let rtl = cg.sect.rtl; + for (gap, (l, r)) in cg.gaps.iter().enumerate() { + let (sl, sr) = (x0 + l * scale, x0 + r * scale); + // Left to right, the space's left edge ends the column before it; right to left, it + // starts the column after it. + let (left, right) = if rtl { (GapGrip::Start, GapGrip::End) } else { (GapGrip::End, GapGrip::Start) }; + // A narrow space is split at its middle, so both edges can be grabbed. + let mid = (sl + sr) / 2.0; + let zones = [ + (left, Rect::from_min_max(pos2(sl - 3.0, bar.min.y), pos2((sl + 3.0).min(mid), bar.max.y))), + (right, Rect::from_min_max(pos2((sr - 3.0).max(mid), bar.min.y), pos2(sr + 3.0, bar.max.y))), + ]; + for (grip, zone) in zones { + if tabs.iter().any(|t| t.intersects(zone)) { + continue; + } + let r = + ui.interact(zone, ui.scope_id().with(("ruler_column", gap, grip)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let r = if r.dragged() { r } else { r.on_hover_text(tl!("Column Edge")) }; + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let v = (pp.x - x0) / scale - cg.origin; + let at = if rtl { cg.sect.text_width() - v } else { v }; + let Some(params) = column_drag(&cg.sect, gap, grip, at) else { continue }; + if !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run_keep_view("layout.columns", params); + } + } + } +} + +/// The `layout.columns` parameters after dragging `grip` of the space after column `gap` (in +/// reading order) to `at` points from the text area's start edge (its right edge in a +/// right-to-left section). Equal columns stay equal, so every space changes, as in Word for Mac; +/// between unequal columns only the column at that edge and the space change. Columns stay at +/// least a quarter inch wide and the space 0–300 pt, snapped to 1/16 inch. `None` when there is +/// no such space or `at` is junk. +pub(crate) fn column_drag(s: &SectionProps, gap: usize, grip: GapGrip, at: f32) -> Option { + let n = s.columns.count as usize; + if !at.is_finite() || !draggable(s) || gap + 1 >= n { + return None; + } + let tw = s.text_width(); + let sep = s.columns.separator; + if s.columns.widths.len() != n { + // Column k is w wide and starts at k·(w + space), w = (tw − (n − 1)·space) / n: solved + // for the space that puts the edge at `at`. + let (i, nf) = (gap as f32, n as f32); + let space = match grip { + GapGrip::End => ((i + 1.0) * tw - at * nf) / (nf - 1.0 - i), + _ => at * nf / (i + 1.0) - tw, + }; + let most = ((tw - MIN_COLUMN * nf) / (nf - 1.0)).clamp(0.0, MAX_SPACE); + // Snapped, and at the limit the last 1/16 inch below it. + let space = (space / 4.5).round() * 4.5; + let space = if space > most { (most / 4.5).floor() * 4.5 } else { space.max(0.0) }; + return Some(json!({"count": n, "space": space, "separator": sep, "apply": "section"})); + } + // Unequal columns: (width, space after) each. + let mut cols = s.columns.widths.clone(); + let start: f32 = cols.iter().take(gap).map(|(w, sp)| w + sp).sum(); + let (&(w0, sp), &(w1, sp1)) = (cols.get(gap)?, cols.get(gap + 1)?); + let at = (at / 4.5).round() * 4.5; + let (end0, start1, end1) = (start + w0, start + w0 + sp, start + w0 + sp + w1); + let (w0, sp, w1) = match grip { + GapGrip::End => { + // `max` and `min` rather than `clamp`, which panics on bounds from a hostile file. + let e = at.min(start1.max(start + MIN_COLUMN)).max((start + MIN_COLUMN).max(start1 - MAX_SPACE)); + (e - start, start1 - e, w1) + } + GapGrip::Start => { + let b = at.min((end1 - MIN_COLUMN).min(end0 + MAX_SPACE)).max(end0); + (w0, b - end0, end1 - b) + } + }; + if let Some(c) = cols.get_mut(gap) { + *c = (w0, sp); + } + if let Some(c) = cols.get_mut(gap + 1) { + *c = (w1, sp1); + } + Some(json!({"widths": cols.iter().map(|(w, sp)| [*w, *sp]).collect::>(), "separator": sep, "apply": "section"})) +} + +/// Whether `layout.columns` takes the section's columns back after a drag: 2–12 columns, unequal +/// ones each at least a quarter inch wide with 0–300 pt after it and no wider together than the +/// text. A file can hold others (up to 45 columns, any widths); their spaces are shown, not +/// dragged. +fn draggable(s: &SectionProps) -> bool { + let n = s.columns.count as usize; + let widths = &s.columns.widths; + if !(2..=12).contains(&n) { + return false; + } + if widths.len() != n { + return true; + } + // The last column has no space after it (the command drops one). + let space = |i: usize, sp: f32| if i + 1 == n { 0.0 } else { sp }; + let total: f32 = widths.iter().enumerate().map(|(i, (w, sp))| w + space(i, *sp)).sum(); + total <= s.text_width() + 1.0 && widths.iter().enumerate().all(|(i, (w, sp))| *w >= MIN_COLUMN && (0.0..=MAX_SPACE).contains(&space(i, *sp))) +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::section::Columns; + + /// Letter paper with inch margins: 468 pt of text. + fn letter(columns: Columns) -> SectionProps { + SectionProps { page_w: 612.0, page_h: 792.0, margin_left: 72.0, margin_right: 72.0, gutter: 0.0, columns, ..Default::default() } + } + + fn equal(count: u32, space: f32) -> SectionProps { + letter(Columns { count, space, ..Default::default() }) + } + + fn widths(v: &Value) -> Vec<(f32, f32)> { + v["widths"].as_array().into_iter().flatten().map(|c| (c[0].as_f64().unwrap_or(0.0) as f32, c[1].as_f64().unwrap_or(0.0) as f32)).collect() + } + + /// #5197: two equal columns half an inch apart leave a space from 216 to 252 pt. + #[test] + fn the_spaces_between_columns_are_where_the_columns_are_not() { + assert_eq!(gaps_of(&equal(2, 36.0)), vec![(216.0, 252.0)]); + assert_eq!(gaps_of(&equal(3, 36.0)), vec![(132.0, 168.0), (300.0, 336.0)]); + assert!(gaps_of(&equal(1, 36.0)).is_empty(), "one column, no space"); + // Right to left, the first column is on the right: the same spaces, read leftwards. + let rtl = SectionProps { rtl: true, ..equal(3, 36.0) }; + assert_eq!(gaps_of(&rtl), vec![(300.0, 336.0), (132.0, 168.0)]); + let unequal = letter(Columns { count: 2, space: 18.0, widths: vec![(100.0, 18.0), (350.0, 0.0)], ..Default::default() }); + assert_eq!(gaps_of(&unequal), vec![(100.0, 118.0)]); + } + + /// #5197: dragging an edge of the space between equal columns keeps them equal and changes + /// the spacing, so the dragged edge lands at the pointer. + #[test] + fn dragging_an_edge_between_equal_columns_changes_the_spacing() { + let s = equal(2, 36.0); + // The first column's end, from 216 to 207: 9 pt more space each side, 18 in all. + let v = column_drag(&s, 0, GapGrip::End, 207.0).unwrap_or_default(); + assert_eq!(v, json!({"count": 2, "space": 54.0, "separator": false, "apply": "section"})); + // The second column's start, from 252 to 261: the same. + assert_eq!(column_drag(&s, 0, GapGrip::Start, 261.0).unwrap_or_default()["space"], 54.0); + // Past the middle the space closes; it never goes negative. + assert_eq!(column_drag(&s, 0, GapGrip::End, 400.0).unwrap_or_default()["space"], 0.0); + // At most 300 pt: 297, the last 1/16 inch below it. + assert_eq!(column_drag(&s, 0, GapGrip::End, 0.0).unwrap_or_default()["space"], 297.0); + // And it never leaves a column narrower than a quarter inch. + let narrow = equal(12, 0.0); + let most = (468.0 - 18.0 * 12.0) / 11.0; + let space = column_drag(&narrow, 0, GapGrip::End, 0.0).unwrap_or_default()["space"].as_f64().unwrap_or(0.0) as f32; + assert!(space <= most, "{space} leaves every column a quarter inch ({most})"); + assert_eq!(space % 4.5, 0.0, "{space} is on the 1/16 inch grid at the limit too"); + // Three columns: the second space's start edge, from 336 to 345. + let s = equal(3, 36.0); + assert_eq!(column_drag(&s, 1, GapGrip::Start, 345.0).unwrap_or_default()["space"], 49.5); + } + + /// #5197: between unequal columns only the column at the dragged edge and the space change. + #[test] + fn dragging_between_unequal_columns_changes_its_neighbours() { + let s = letter(Columns { count: 3, space: 18.0, widths: vec![(100.0, 18.0), (200.0, 18.0), (132.0, 0.0)], ..Default::default() }); + // The first column's end, 100 → 90: it narrows and the space after it widens. + assert_eq!(widths(&column_drag(&s, 0, GapGrip::End, 90.0).unwrap_or_default()), vec![(90.0, 28.0), (200.0, 18.0), (132.0, 0.0)]); + // The third column's start, 336 → 300, back into the second column: it stops at the + // second column's end (318), the space closes and the third column takes it. + assert_eq!(widths(&column_drag(&s, 1, GapGrip::Start, 300.0).unwrap_or_default()), vec![(100.0, 18.0), (200.0, 0.0), (150.0, 0.0)]); + // The second column's start, 118 → 1000: the column never gets narrower than a quarter + // inch. + assert_eq!(widths(&column_drag(&s, 0, GapGrip::Start, 1000.0).unwrap_or_default()), vec![(100.0, 200.0), (18.0, 18.0), (132.0, 0.0)]); + } + + /// Junk positions and spaces that aren't there change nothing. + #[test] + fn column_drags_ignore_junk() { + let s = equal(2, 36.0); + assert!(column_drag(&s, 0, GapGrip::End, f32::NAN).is_none()); + assert!(column_drag(&s, 1, GapGrip::End, 100.0).is_none(), "two columns have one space"); + assert!(column_drag(&equal(1, 36.0), 0, GapGrip::Start, 100.0).is_none()); + assert!(column_drag(&equal(13, 0.0), 0, GapGrip::End, 10.0).is_none(), "more columns than the command takes"); + // Unequal widths from a file that the command wouldn't take back aren't dragged. + let narrow = letter(Columns { count: 2, widths: vec![(5.0, 0.0), (463.0, 0.0)], ..Default::default() }); + assert!(column_drag(&narrow, 0, GapGrip::End, 30.0).is_none(), "a column under a quarter inch"); + let wide = letter(Columns { count: 2, widths: vec![(100.0, 400.0), (50.0, 0.0)], ..Default::default() }); + assert!(column_drag(&wide, 0, GapGrip::End, 90.0).is_none(), "a space over 300 pt"); + let over = letter(Columns { count: 2, widths: vec![(300.0, 18.0), (300.0, 0.0)], ..Default::default() }); + assert!(column_drag(&over, 0, GapGrip::End, 290.0).is_none(), "wider than the text"); + let last = letter(Columns { count: 2, widths: vec![(200.0, 18.0), (250.0, 999.0)], ..Default::default() }); + assert!(column_drag(&last, 0, GapGrip::End, 190.0).is_some(), "the last column's space after it doesn't count"); + } + + /// Through the command: a drag's result is applied to the caret's section, keeps the line + /// between columns, and is one undo step. + #[test] + fn a_column_drag_sets_the_sections_columns() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one two three")), Default::default()); + app.run("layout.columns", json!({"count": 2, "separator": true})).unwrap(); + let before = wordcraft_engine::cmd::page::sect(&app.session); + // The first column's end, 9 pt to the left: 18 pt more space, the columns still equal. + let end = before.column_boxes()[0].0 + before.column_boxes()[0].1; + let params = column_drag(&before, 0, GapGrip::End, end - 9.0).unwrap(); + app.run("layout.columns", params).unwrap(); + let after = wordcraft_engine::cmd::page::sect(&app.session); + assert_eq!(after.columns.count, 2); + assert!(after.columns.widths.is_empty(), "the columns stay equal"); + assert_eq!(after.columns.space, before.columns.space + 18.0); + assert!(after.columns.separator, "the line between the columns stays"); + let boxes = after.column_boxes(); + assert!((boxes[0].1 - (end - 9.0)).abs() < 0.01, "the edge is at the pointer: {boxes:?}"); + let laid = app.session.layout(); + let page = laid.pages.first().unwrap(); + let cg = column_gaps(&app, &laid, 0).unwrap(); + assert_eq!(cg.gaps.len(), 1); + assert!((cg.gaps[0].0 - (page.body.x + boxes[0].1)).abs() < 0.01, "the ruler's space starts where the first column ends"); + assert!(app.session.undo()); + assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).columns, before.columns); + } + + /// A drag sets the columns of every section the selection touches, so a selection across + /// sections gets no spaces on the ruler; nor does a page the caret isn't on. + #[test] + fn no_column_spaces_for_a_selection_across_sections_or_another_page() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one\ntwo")), Default::default()); + app.run("caret.docEnd", json!({})).unwrap(); + app.run("layout.break", json!({"kind": "nextPage"})).unwrap(); + app.run("layout.columns", json!({"count": 2, "apply": "section"})).unwrap(); + let laid = app.session.layout(); + assert_eq!(laid.pages.len(), 2); + assert!(column_gaps(&app, &laid, 1).is_some(), "the caret's page shows the second section's columns"); + assert!(column_gaps(&app, &laid, 0).is_none(), "the first page has one column"); + app.run("select.all", json!({})).unwrap(); + let laid = app.session.layout(); + assert!(column_gaps(&app, &laid, 1).is_none(), "the selection runs into a section of one column"); + } +} diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index 0362bbee81..86f5d89e72 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -24,6 +24,11 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[ hp.rect_filled(bar, 0.0, t.ruler_margin); let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y)); hp.rect_filled(text, 0.0, t.ruler); + // The spaces between the caret section's text columns, shaded like the margins (#5197). + let columns = super::column_ruler::column_gaps(app, layout, pi); + if let Some(cg) = &columns { + super::column_ruler::draw(&hp, cg, bar, x0, scale, &t); + } let rp = app.session.doc.para_at(&app.session.sel.focus).map(|para| app.session.doc.styles.resolve_para(¶.props)); // Graduations in the File › Options unit. let (unit, div, step) = ruler_scale(app.session.units()); @@ -101,6 +106,11 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[ // spans the text area, so a border marker keeps its drag) and before the indent markers, // which keep the pointer where they overlap. crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale); + // Outside a table, the spaces between the text columns, in the same order (#5197). + if let Some(cg) = &columns { + let tabs = crate::ruler_tabs::marker_rects(app, bar, &px, &rp.tabs); + super::column_ruler::handles(app, ui, cg, bar, x0, scale, &tabs); + } // Dragging the left-indent marker. let id = ui.scope_id().with("ruler_left"); let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0)); diff --git a/docs/gaps.md b/docs/gaps.md index 7527aa05d3..6bef0c7165 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -217,6 +217,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-31 | trivial | Pagination gap #4: a float anchored right after a page break inside a paragraph, before text, is drawn on the new page (#4262) | | 2026-10-31 | trivial | Pagination gap #4: a float 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) | | 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-11-02 | trivial | In Print Layout the horizontal ruler shades the spaces between the caret section's text columns, and their edges drag the spacing (#5197); no ranked gap changed; 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-11-04 | trivial | #19: ODT settings for what the standard leaves open removed: one choice for each case | | 2026-11-04 | trivial | #19: ODT element audit; files from other applications checked | | 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/ui-parity.md b/docs/ui-parity.md index 84b19512a2..db5c197d59 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -42,9 +42,10 @@ Document title opens Rename Document; the account badge opens Options › Genera | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); Return on the Print page prints, as in Word, whose ⌘P opens on Print as the default button (not while a field or list on the page has the keyboard); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129; File › Options works there, AutoCorrect Options included, #4585), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text. Inside a Drawing Canvas a click picks the member under it (shape, picture or text box) on its own, its frame and handles shown inside the canvas frame; it is dragged, resized, turned and nudged inside the canvas, and Shape Format, Delete, Cut and Copy act on it alone; a click in a text box member's text puts the caret there (#2637, `objects::tests::clicking_and_dragging_inside_a_canvas_works_on_its_members`). Not yet: Shift+click on several members, dragging a member out of its canvas, drag-selecting a text box member's text, members of a turned canvas | ✅ | 75% | — | +| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878); in a section of several text columns the spaces between them are shaded like the margins (Print Layout, in the main text outside tables, with the selection in one section), an edge dragged changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) | ✅ | 92% | 1 | +| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332), and the arrow over each handle turns with the frame to the nearest of the four resize arrows (#3932); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | -| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a bold insertion cursor (solid, twice the caret's width) follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234, #4195) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 | @@ -118,6 +119,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 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. | +| 2026-11-01 | trivial | Rulers: the spaces between text columns are shaded on the horizontal ruler (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge changes the spacing; equal columns stay equal, as in Word for Mac (#5197) | | 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. | From 33fa77a89241c88ef1135c4b6f706180bc4f3288 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:51:36 +0800 Subject: [PATCH 13/47] Track Changes: resolving away an empty paragraph with a section break keeps the next paragraph's properties (#5667) (#5734) Rejecting an inserted paragraph (or accepting a deleted one) whose mark carries a section break left it empty; remove_empty_para refused it for the break, so the next paragraph was joined into it and took its properties (style, numbering, indents). The empty paragraph now goes whole and its section joins the following one first, as deleting the break does (remove_section_break). Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/engine/src/cmd/review/changes.rs | 19 ++++++++--- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++ docs/typing-parity.md | 2 ++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e49fab3a5..16afcb20d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -351,6 +351,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 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-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | | 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 | @@ -869,6 +870,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 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) | | 2026-11-06 | trivial | Progress log: the printer list no longer waits on printer drivers (#5710) | +| 2026-11-06 | trivial | Progress log: resolving away an empty paragraph with a section break keeps the next paragraph's properties, #5667; readiness estimates unchanged | | 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-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged | | 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/review/changes.rs b/crates/engine/src/cmd/review/changes.rs index b41cc7f01b..13990ba89c 100644 --- a/crates/engine/src/cmd/review/changes.rs +++ b/crates/engine/src/cmd/review/changes.rs @@ -54,11 +54,20 @@ fn resolve_para( // An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away: // the next paragraph joins this one (the split is undone). A paragraph left empty goes away // whole, so the next one keeps its own style and numbering, as in Word. The mark at the end - // of a story or cell can't go, so it just stops being a revision. - if ((del.is_some() && accept) || (ins.is_some() && !accept)) - && (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?) - { - return Ok(()); + // of a story or cell can't go, so it just stops being a revision. An empty paragraph whose + // mark carries a section break goes whole too: its section joins the following one, as when + // the break is deleted (#5667). Joining instead gave the next paragraph its properties. + if (del.is_some() && accept) || (ins.is_some() && !accept) { + let sectioned = story == StoryRef::Body + && path.0.len() == 1 + && s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some()) + && crate::cmd::has_next_para(s, story, path); + if sectioned { + s.doc.remove_section_break(path.last()); + } + if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? { + return Ok(()); + } } let para = s.doc.para_mut(story, path)?; para.mark.ins = None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 10591fa908..4e5c490c8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -12787,3 +12787,48 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); } + +/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a +/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is +/// tracked as an insertion by `rev`, or as a deletion when `delete`. +fn sectioned_revision(delete: bool) -> Session { + use wordcraft_doc::{Revision, RevisionKind}; + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); + run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); + run(&mut s, "para.style", json!({"style": "Heading1"})); + let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; + s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); + s.doc.last_section.margin_left = 72.0; + let mut sect = s.doc.last_section.clone(); + sect.margin_left = 144.0; + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + let len = p.len(); + p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); + if delete { + p.mark.del = Some(0) + } else { + p.mark.ins = Some(0) + } + p.props.indent_left = Some(36.0); + p.section = Some(Box::new(sect)); + s +} + +#[test] +fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { + // #5667: the resolved paragraph was left empty but kept for its section break, so the next + // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, + // as an empty paragraph without a break does, and its section joins the following one. + for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { + let mut s = sectioned_revision(delete); + assert_eq!(s.doc.sections().len(), 2); + run(&mut s, cmd, json!({})); + assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); + let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); + assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); + assert_eq!(s.doc.sections().len(), 1, "{cmd}"); + assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); + } +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index ad1f879c5b..266d9cf9a4 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -114,6 +114,7 @@ formatting of the text before the cursor. Not yet compared side by side in Word. | Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` | | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | | Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | +| Accept All / Reject All of a paragraph left empty, whose mark carries a section break (tracking on, or a Compare result) | the paragraph goes whole and its section joins the following one, as when the break is deleted; the next paragraph keeps its own style and numbering, as for an empty paragraph without a break (#5667) | `resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | | Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table (each cell as below). A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | | Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` | @@ -589,6 +590,7 @@ this correction; native Windows interaction and Microsoft Word remain untested. |---|---|---| | 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-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) | | 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-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 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 796b39dde6b5e48a7831726054d30702f43bb71d Mon Sep 17 00:00:00 2001 From: Senura Dissanayake Date: Sun, 8 Nov 2026 04:54:06 +0100 Subject: [PATCH 14/47] docbin: open Word 97-2003 files in compatibility mode 11 (#5905) Word 97-2003 documents were laid out with the model default of mode 15. Word opens them in mode 11, which changes justification and line breaking. Test: a_doc_opens_in_word_97_2003_compatibility_mode --- contributors/people.toml | 4 ++++ crates/docbin/src/lib.rs | 3 +++ crates/docbin/src/tests.rs | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 04fc37ec2f..4e6dea3b12 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -14,6 +14,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.SenuraMalaka] +real_name = "Senura Dissanayake" +sync_github_name = true + [people.divyanshu2074] real_name = "Divyanshu Gupta" sync_github_name = true diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index a2461d3fb2..e143bd9763 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> }; let mut doc = Document::new(); + // Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default + // 15 would apply Word 2013+ justification and line-breaking rules to them. + doc.settings.compat_mode = 11; if let Some(core) = metadata::read(&mut comp) { doc.core = core; } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 22ec2fc1e4..3acc38cd1b 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() { assert_eq!(doc.body.len(), 1); } +#[test] +fn a_doc_opens_in_word_97_2003_compatibility_mode() { + let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + // Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and + // line breaking. + assert_eq!(doc.settings.compat_mode, 11); +} + #[test] fn header_text_stays_out_of_body() { let f = text_doc( From fa791d29b279be732956801f6c42d014b5114033 Mon Sep 17 00:00:00 2001 From: Bardan Ghimire Date: Sun, 8 Nov 2026 09:41:38 +0545 Subject: [PATCH 15/47] test(mcp): gate the names helper to unix, where its only test runs (#5879) Co-authored-by: Bardan --- crates/mcp/tests/automation_roots.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index e870fde1f9..61b1d58462 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -208,6 +208,7 @@ fn links_out_of_a_root_are_refused() { } /// The names in `dir`, sorted. +#[cfg(unix)] fn names(dir: &Path) -> Vec { let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); names.sort(); From 625f598b64b0868c7ed4881e3e36c210433fe42a Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 00:56:55 -0300 Subject: [PATCH 16/47] Fix #5750: style-set gallery follows RTL interface direction (#5857) --- crates/ui-egui/src/previews.rs | 2 +- crates/ui-egui/src/ribbon/design.rs | 5 ++-- crates/ui-egui/src/ribbon/tests.rs | 43 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 5d02c834dd..1ff99e1073 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -694,7 +694,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { /// Style sets shown in the Design tab's gallery, in order. const STYLE_SETS: [&str; 7] = ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"]; /// Width of one gallery thumbnail, points. -const STYLE_SET_W: f32 = 56.0; +pub const STYLE_SET_W: f32 = 56.0; /// Width the whole gallery takes with the ribbon's 1 pt item spacing. pub const STYLE_SET_GALLERY_W: f32 = STYLE_SETS.len() as f32 * (STYLE_SET_W + 1.0) - 1.0; diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs index 8208c86318..9b078e2058 100644 --- a/crates/ui-egui/src/ribbon/design.rs +++ b/crates/ui-egui/src/ribbon/design.rs @@ -3,11 +3,12 @@ use super::{CONTENT_H, Rect, Sense, Target, Ui, WordApp, big, group, json, menu_ /// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped /// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by, -/// the thumbnails (#638). Returns the box and the space the thumbnails used. +/// the thumbnails (#638). In a right-to-left interface, the thumbnails lay out from the right +/// (#5750). Returns the box and the space the thumbnails used. pub(super) fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) { ui.add_space(4.0); let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover()); - let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min))); + let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(crate::rtl::row_layout(ui, egui::Align::Min))); gui.set_clip_rect(gallery.intersect(ui.clip_rect())); gui.spacing_mut().item_spacing = vec2(1.0, 1.0); crate::previews::style_set_gallery(app, &mut gui); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index 745750b5a3..444c60acb1 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -113,6 +113,49 @@ fn design_style_set_gallery_stays_in_its_box() { assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } +/// #5750: in a right-to-left interface, the Design tab's style-set gallery lays out from the +/// right: the first style set ("default") is at the right edge, the remaining thumbnails read to +/// the left, and the next controls start after a gap to the left of the gallery. +#[test] +fn design_style_set_gallery_reads_from_the_right_in_rtl() { + crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n")); + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut seen = None; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 200.0)).build_ui(|ui| { + crate::rtl::root(ui, |ui| { + crate::rtl::horizontal_top(ui, |ui| { + let (gallery, used) = style_set_box(&mut app, ui); + seen = Some((gallery, used, ui.cursor().max.x)); + }); + }); + }); + h.run_steps(2); + let tiles: Vec = h.ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| (w.rect.width() - crate::previews::STYLE_SET_W).abs() < 0.1 && (w.rect.height() - CONTENT_H).abs() < 0.1) + .map(|w| w.rect) + .collect() + }); + drop(h); + crate::i18n::set_current(crate::i18n::Lang::EN); + + let (gallery, used, next_x) = seen.unwrap(); + assert!((gallery.width() - crate::previews::STYLE_SET_GALLERY_W).abs() < 0.01); + assert!(used.left() >= gallery.left() - 0.01, "{used:?} {gallery:?}"); + assert!(used.right() <= gallery.right() + 0.01, "{used:?} {gallery:?}"); + assert!(next_x <= gallery.left() - 8.0 + 0.01, "{next_x} {gallery:?}"); + + assert_eq!(tiles.len(), 7, "7 style set tiles"); + assert!((tiles[0].right() - gallery.right()).abs() < 0.01, "default tile at the right edge: {tiles:?} {gallery:?}"); + assert!((tiles[6].left() - gallery.left()).abs() < 0.01, "minimalist tile at the left edge: {tiles:?} {gallery:?}"); + for i in 1..tiles.len() { + assert!(tiles[i].right() <= tiles[i - 1].left(), "tile {i} is to the left of tile {}", i - 1); + } +} + /// #2738: Home › Underline's drop-down: every line sample is a named entry (assistive /// technology, tooltips) that applies its own style; Words only, None and Underline Color › /// Automatic do what they say. From b016ecc26cb88323c4fc67b9a2a1d4fee9a34e6c Mon Sep 17 00:00:00 2001 From: Bardan Ghimire Date: Sun, 8 Nov 2026 09:44:16 +0545 Subject: [PATCH 17/47] fix: reject non-regular files when reading template titles (#5904) Co-authored-by: Bardan --- crates/engine/src/io.rs | 4 ++-- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5ffc345e53..583da36e89 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bo #[cfg(not(target_arch = "wasm32"))] pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { crate::file_access::check_read(&path.to_string_lossy()).ok()?; - let file = std::fs::File::open(path).ok()?; - if file.metadata().ok()?.len() > max_file { + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > max_file { return None; } wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5c490c8e..2718ef8de6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5747,6 +5747,39 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() { assert!(device.unwrap_err().contains("not a file")); } +#[cfg(unix)] +#[test] +fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() { + let dir = scratch_dir("template-title-fifo"); + let fifo = dir.join("pipe.dotx"); + + let made = std::process::Command::new("mkfifo").arg(&fifo).status(); + if !made.is_ok_and(|s| s.success()) { + let _ = std::fs::remove_dir_all(&dir); + eprintln!("mkfifo unavailable; skipping"); + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + let path = fifo.clone(); + + std::thread::spawn(move || { + let title = crate::io::package_title(&path, 64 << 20, 64 << 10); + let _ = tx.send(title); + }); + + let result = rx.recv_timeout(std::time::Duration::from_secs(10)); + + if result.is_err() { + // Unblock a stuck reader so the test thread can finish. + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); + } + + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(result.expect("reading a template title blocked on a named pipe"), None); +} + #[cfg(unix)] #[test] fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() { From 5f1db14954217431d1d17f578e73bcd64b74c995 Mon Sep 17 00:00:00 2001 From: Ashok Ram Date: Sun, 8 Nov 2026 09:31:43 +0530 Subject: [PATCH 18/47] Proofing: Check spelling as you type is one setting for every open document (#5740) (#5766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Proofing: Check spelling as you type is one setting for every open document (#5740) Each open document's view kept its own copy of the setting. Turned off with one document active, it stayed on in the others, and the settings saved for the next run took the value of whichever document was active then, so it came back on. A document made active now takes the saved preference, as Read Mode's page colour already does. * Contributors: credit ashokram-si as Ashok Ram * Proofing: every view made active follows the app-wide settings, a window's view put back too (#5740) put_view restored a window's view with the Check spelling as you type value it had when it was taken, after switch_to had applied the shared preference, so the settings saved for the next run could take the old value again. One ViewState::follow_prefs now applies the app-wide settings a view keeps a copy of (Read Mode's page colour and Check spelling as you type) wherever a view is made active: switch_to, a new document's view, set_document and put_view. * test(ui): Check spelling off in a second window stays off in every window (#5740) The whole sequence through the app: View › New Window, spelling turned off in the second window, the first window's own view put back (put_view), a new document's window, back to the second window, then the saved settings read by a new app. Fails if put_view doesn't follow the setting. --------- Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com> --- contributors/people.toml | 3 ++ crates/engine/src/session/documents.rs | 4 +-- crates/engine/src/session/snapshot.rs | 2 +- crates/engine/src/session/views.rs | 2 +- crates/engine/src/tests/documents.rs | 48 ++++++++++++++++++++++++++ crates/engine/src/view.rs | 11 ++++++ crates/ui-egui/src/tests.rs | 45 ++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4e6dea3b12..1f5825f1ad 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -201,3 +201,6 @@ display_name = "entropybeater" [people.helenkwok] real_name = "Helen Kwok" + +[people.ashokram-si] +real_name = "Ashok Ram" diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index f985d8e35d..ccbbdf4120 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -390,7 +390,7 @@ impl Session { let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; let mut d = self.others.swap_remove(at); self.swap_doc(&mut d); - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); self.others.push(d); Ok(()) } @@ -471,7 +471,7 @@ impl Session { doc.ensure_nonempty(); let mut view = self.view.clone(); view.outline.collapsed.clear(); - view.read_page_color = self.prefs.read_page_color; + view.follow_prefs(&self.prefs); let mut d = DocState::new(doc, untitled, view); let id = d.document_id; self.order.push(id); diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 504d3d805a..2a9283f3f8 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -32,7 +32,7 @@ impl Session { self.hf_return = None; self.go_back_step = 0; self.view.outline.collapsed.clear(); - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); // A copy the replaced document kept stays in the recovery list. #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 762c1d846c..9be8596052 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -135,7 +135,7 @@ impl Session { let current = document_id == self.document_id && rev == self.rev; self.sel = sel; self.view = view; - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); self.pending = pending; self.goal_x = goal_x; self.page_hint = page_hint; diff --git a/crates/engine/src/tests/documents.rs b/crates/engine/src/tests/documents.rs index 36ecb471f2..7dd108b893 100644 --- a/crates/engine/src/tests/documents.rs +++ b/crates/engine/src/tests/documents.rs @@ -346,3 +346,51 @@ fn input_method_joins_stay_with_their_window() { assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); drop(a); } + +/// Check spelling as you type is one setting for the app, not one per open document (#5740): +/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off +/// in the others too, and switching to another document doesn't turn it back on, so what is saved +/// for the next run is what was chosen, whichever document is active then. +#[test] +fn check_spelling_is_one_setting_for_every_open_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + run(&mut s, "file.new", json!({})); + let c = s.document_id(); + assert!(s.view.proofing); + s.activate(a).unwrap(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + for id in [b, c, a] { + s.activate(id).unwrap(); + assert!(!s.view.proofing, "off in document {id}"); + assert!(!s.prefs.check_spelling); + } + run(&mut s, "review.proofing", json!({"value": true})); + for id in [b, c] { + s.activate(id).unwrap(); + assert!(s.view.proofing, "back on in document {id}"); + } + // A document opened now starts with the setting as it is. + run(&mut s, "review.proofing", json!({"value": false})); + run(&mut s, "file.new", json!({})); + assert!(!s.view.proofing); +} + +/// A window's view put back after Check spelling as you type changed in another window takes the +/// setting as it is now, not as it was when the window's view was taken (#5740): every view made +/// active follows the saved preference. +#[test] +fn a_window_view_put_back_follows_the_spelling_setting() { + let mut s = s(); + assert!(s.view.proofing); + let held = s.take_view(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + s.put_view(held); + assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); + let held = s.take_view(); + run(&mut s, "review.proofing", json!({"value": true})); + s.put_view(held); + assert!(s.view.proofing); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index baae9d6033..c10d6d204a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -161,6 +161,17 @@ fn default_read_zoom() -> f32 { 1.0 } +impl ViewState { + /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's + /// page colour and Check spelling as you type (#5740). A view made active goes through this + /// (a document switched to, opened or made, a window's view put back), so a change made while + /// another view was active holds in this one too. + pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { + self.read_page_color = prefs.read_page_color; + self.proofing = prefs.check_spelling; + } +} + impl Default for ViewState { fn default() -> Self { ViewState { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 7e24875be4..4c069a3755 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -5837,6 +5837,51 @@ fn check_spelling_survives_restart() { assert!(second.session.prefs.check_spelling, "the live spelling setting and its saved copy agree"); } +/// With several documents open, turning Check spelling as you type off in one keeps it off in the +/// others, and the next run starts with it off whichever document was active when the settings +/// were saved (#5740: it came back on every time the file was opened again). +#[test] +fn check_spelling_off_survives_a_switch_and_a_restart() { + let mut first = app(); + let a = first.session.document_id(); + first.run("file.new", json!({})).unwrap(); + first.run("file.options", json!({"checkSpelling": false})).unwrap(); + first.session.activate(a).unwrap(); + assert!(!first.session.view.proofing, "off in the other document too"); + let saved = serde_json::to_vec(&first.prefs()).unwrap(); + + let mut second = app(); + second.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert!(!second.session.view.proofing && !second.session.prefs.check_spelling); +} + +/// The same across windows: Check spelling as you type turned off in a second window on a document +/// stays off when the first window's own view of it comes back, in a window on another document, +/// back in the second window, and in the saved settings (#5740). +#[test] +fn check_spelling_off_in_a_second_window_stays_off_in_every_window() { + let mut a = app(); + let first = a.windows.shown(); + a.new_window(); + let second = a.windows.shown(); + assert_ne!(first, second, "View › New Window opened a window with its own view"); + a.run("file.options", json!({"checkSpelling": false})).unwrap(); + + assert!(a.enter_window(first)); + assert!(!a.session.view.proofing, "the first window's own view, put back, follows the setting"); + a.run("file.new", json!({})).unwrap(); + assert!(!a.session.view.proofing, "a new document's window"); + assert!(a.enter_window(second)); + assert!(!a.session.view.proofing, "back in the second window"); + assert!(a.enter_window(first)); + assert!(!a.session.view.proofing); + + let saved = serde_json::to_vec(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert!(!b.session.view.proofing && !b.session.prefs.check_spelling, "saved off, whichever window was drawn"); +} + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); From 855a0fe9bde7240d9edcc359e3be7d0b654236e6 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:03:54 +0100 Subject: [PATCH 19/47] Layout: a nested table's Can't Split row moves on with its outer row (#5456) A row is split across pages from its flattened items, so a row of a nested table marked w:cantSplit was cut between its lines like any other text in the outer cell. Word leaves such a row whole: the outer row moves to the next page whole when that row comes first in it (docxide-pdf nested-first-row-prefix300/330-cant1), and otherwise splits above it (inferred). table_flow now moves the cut up to just below the top of the highest nested Can't Split row it runs through (nested_keep_cut): the outer row's bottom band below that top, so the text and nested rows ending there stay on the page. A row runs through the cut when it ends below the room left for text, as in the split itself; one walk over the rows from the lowest top up moves the cut until none does. A vertically merged cell doesn't stretch its row's band, and a cell aligned to the middle or bottom is measured from its top, as the split runs it. This applies to the first part of a row and to the kept-chain and header-row checks. On a fresh page, where moving on gains nothing, a nested row taller than the room still splits at the room rather than running into the bottom margin. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/layout/src/table_flow.rs | 69 +++++- crates/layout/src/tests.rs | 1 + crates/layout/src/tests/nested_cant_split.rs | 213 +++++++++++++++++++ docs/gaps.md | 2 + docs/layout-parity.md | 3 +- 6 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 crates/layout/src/tests/nested_cant_split.rs diff --git a/ROADMAP.md b/ROADMAP.md index 16afcb20d1..249f287f99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -236,6 +236,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged. - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. +- 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. 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. @@ -912,6 +913,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | +| 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index a05680f14a..88757e3368 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -94,7 +94,62 @@ fn row_keeps_next(row: &table::RowLayout) -> bool { .unwrap_or(false) } -fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool { +/// The cut that leaves every row of a nested table marked "can't split" whole: just below the +/// top of the highest such row the cut at `room` runs through, moved up again while it runs +/// through another one (Word moves such a row on with the rest of the outer row, observed in the +/// docxide-pdf `nested-first-row-*-cant1` cases). Like the split itself, a row "runs through" +/// the cut when it ends below the room left for text there, above the row's bottom band; the cut +/// goes that band below the row's top, so the text and rows ending at its top stay. +/// shortcut: the moved row's side borders and shading keep that band's height on the page, and +/// on the next page its cell area and shading start that band less its top margin below its +/// first line (clicks there miss the cell). Only an explicit bottom cell margin or a thick outer +/// border shows it, never Word's defaults; upgrade with a separate drawing cut in split_row that +/// moves a nested cell starting between the limit and the cut whole from its top. +fn nested_keep_cut(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> f32 { + let own = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min(); + // Each nested row's top and bottom when it can't split. A vertically merged cell runs past + // its own row, so a row ends where its shortest cell does. + let mut bands: std::collections::BTreeMap<(StoryRef, &[u32], usize), Option<(f32, f32)>> = std::collections::BTreeMap::new(); + // The split runs a cell aligned to the middle or bottom from its top: so do its bands. + let mut shift = vec![0.0f32; row.items.len()]; + for al in row.cells.iter().filter_map(|c| c.align.as_ref()) { + shift.get_mut(al.content.clone()).into_iter().flatten().for_each(|d| *d = al.dy); + } + for (it, &dy) in row.items.iter().zip(&shift) { + let Placed::Cell { rect, table, row: r, story, .. } = it else { continue }; + if Some(table.0.len()) <= own { + continue; + } + let rect = Rect { y: rect.y - dy, ..*rect }; + let band = bands + .entry((*story, table.0.as_slice(), *r)) + .or_insert_with(|| doc.table(*story, table).and_then(|t| t.rows.get(*r)).filter(|r| r.props.cant_split).map(|_| (rect.y, rect.bottom()))); + if let Some((top, bottom)) = band { + *top = top.min(rect.y); + *bottom = bottom.min(rect.bottom()); + } + } + let below = row.bottom.clamp(0.0, room.max(0.0)); + let mut bands: Vec<(f32, f32)> = bands.into_values().flatten().collect(); + // From the lowest top up: once the cut moves up to a row, the rows starting below it are + // wholly under it, and a row the old cut ran through still runs through the new one. + bands.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut cut = room; + for (top, bottom) in bands { + let limit = cut - below; + if top < limit && bottom > limit + 0.01 { + cut = top + below; + } + } + cut +} + +/// [`table::split_row_keeping_cells`] that doesn't split a nested "can't split" row either. +fn split_keeping_nested(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> Option<(table::RowLayout, table::RowLayout)> { + table::split_row_keeping_cells(row, nested_keep_cut(doc, row, room)) +} + +fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool { if laid.height <= room + 0.01 { return true; } @@ -104,7 +159,7 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) { return false; } - table::split_row_keeping_cells(laid, room).is_some() + split_keeping_nested(doc, laid, room).is_some() } pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { @@ -187,8 +242,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc { let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::(); let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split); - if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some())) - { + if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) { pb.advance(block, body_top); } } @@ -223,7 +277,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc } let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false }; let page_height = pb.bottom - body_top - repeated_height; - height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height) + height <= page_height + 0.01 && !row_can_start(ctx.doc, next, laid, pb.bottom - pb.y - height, page_height) }); // A row another row follows needs room for the border below it too (measured in Word). if pb.y + cur.height + cur.edge <= pb.bottom + 0.01 && !defer_kept { @@ -238,8 +292,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc let moves_on = first_part && short_of_min(ri, room); let split = match (splittable && !moves_on && !defer_kept, first_part) { (false, _) => None, - (true, true) => table::split_row_keeping_cells(cur, room), - (true, false) => table::split_row(cur, room), + (true, true) => split_keeping_nested(ctx.doc, cur, room), + // On a fresh page, a nested row taller than the room splits rather than overflowing. + (true, false) => table::split_row(cur, nested_keep_cut(ctx.doc, cur, room)).or_else(|| table::split_row(cur, room)), }; match split { Some((a, b)) => { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 29114735d0..2b1c4eb15b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13,6 +13,7 @@ mod grid; mod ligature; pub(crate) mod linear; mod nbsp; +mod nested_cant_split; mod numeral; mod pages; mod styleref; diff --git a/crates/layout/src/tests/nested_cant_split.rs b/crates/layout/src/tests/nested_cant_split.rs new file mode 100644 index 0000000000..aeccb9901b --- /dev/null +++ b/crates/layout/src/tests/nested_cant_split.rs @@ -0,0 +1,213 @@ +//! A row of a nested table marked "can't split" moves on with its outer row, as in Word +//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it. +use crate::*; +use wordcraft_doc::{Table, para_block}; + +fn one_line_paras(what: &str, n: usize) -> Vec> { + (0..n) + .map(|i| { + let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default()); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + para_block(p) + }) + .collect() +} + +/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold +/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns, +/// per cell, the prefix lines and the nested lines on each page. +fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec, Vec)> { + let mut d = Document::from_text("Filler"); + if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) { + p.props.space_before = Some(push); + } + let mut outer = Table::new(1, cells.len(), 440.0); + for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) { + let mut inner = Table::new(1, 1, 200.0); + inner.rows[0].props.cant_split = cant_split; + inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested); + cell.blocks = one_line_paras("Prefix", prefix); + cell.blocks.push(Arc::new(Block::Table(inner))); + cell.blocks.push(para_block(Paragraph::with_text("", Default::default()))); + } + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions::default()); + let count = |cell: u32, nested: bool| -> Vec { + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l0, l1, .. } => { + let p = &path.0; + let hit = if nested { + p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0] + } else { + p.len() == 4 && p[..3] == [1, 0, cell] + }; + hit.then_some(l1 - l0) + } + _ => None, + }) + .sum() + }) + .collect() + }; + (0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect() +} + +#[test] +fn nested_cant_split_row_moves_on_with_its_outer_row() { + let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1; + assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}"); + assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole"); +} + +#[test] +fn the_outer_row_splits_above_a_nested_cant_split_row() { + // About 22 lines of room: the nested row (lines 10 to 40) runs through it. + let cell = &lines_per_page(340.0, &[(10, 30)], true)[0]; + assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}"); + assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}"); +} + +#[test] +fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() { + // Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the + // cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too. + let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true); + assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}"); + assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}"); + assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}"); +} + +#[test] +fn nested_cant_split_row_taller_than_a_page_still_splits() { + let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1; + assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}"); + assert_eq!(pages.iter().sum::(), 60, "every line is placed once: {pages:?}"); + assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}"); +} + +#[test] +fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() { + let mut nested = Table::new(40, 1, 200.0); + nested.props.cell_margins = Some([0.0; 4]); + for (i, row) in nested.rows.iter_mut().enumerate() { + row.props.cant_split = true; + row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2); + } + let mut outer = Table::new(1, 1, 400.0); + // A bottom margin: the part left on the page keeps it below the last row there. + outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()]; + let l = lay_pushed(520.0, outer); + let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]); + assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}"); + assert_eq!(pages.iter().sum::(), 80, "every line is placed once: {pages:?}"); + for r in 0..40u32 { + let on: Vec = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]); + assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}"); + } +} + +fn empty() -> Arc { + para_block(Paragraph::with_text("", Default::default())) +} + +/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs. +fn kept_nested(lines: usize) -> Arc { + let mut t = Table::new(1, 1, 200.0); + t.rows[0].props.cant_split = true; + t.rows[0].cells[0].blocks = one_line_paras("Nested", lines); + Arc::new(Block::Table(t)) +} + +/// Lay out a filler paragraph pushed `push` points down, then `outer`. +fn lay_pushed(push: f32, outer: Table) -> DocLayout { + let mut d = Document::from_text("Filler"); + if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) { + p.props.space_before = Some(push); + } + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + let mut c = LayoutCache::new(); + layout(&d, &mut c, &LayoutOptions::default()) +} + +/// The lines on each page of the body paragraphs whose path `hit` picks. +fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec { + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0), + _ => None, + }) + .sum() + }) + .collect() +} + +#[test] +fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() { + // Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top, + // where its nested row (lines 2 to 32) runs through the room of about 22 lines. + let mut outer = Table::new(1, 2, 440.0); + outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50); + outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom); + let mut blocks = one_line_paras("Prefix", 2); + blocks.extend([kept_nested(30), empty()]); + outer.rows[0].cells[1].blocks = blocks; + let l = lay_pushed(340.0, outer); + let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]); + assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}"); +} + +#[test] +fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() { + let mut outer = Table::new(2, 1, 400.0); + outer.rows[0].props.header = true; + outer.rows[0].cells[0].blocks = one_line_paras("Header", 1); + outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()]; + let l = lay_pushed(520.0, outer); + let header = per_page(&l, |p| p == [1, 0, 0, 0]); + assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}"); + assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]); +} + +#[test] +fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() { + let mut outer = Table::new(2, 1, 400.0); + let mut kept = one_line_paras("Kept", 1); + if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) { + p.props.keep_next = Some(true); + } + outer.rows[0].cells[0].blocks = kept; + outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()]; + let l = lay_pushed(520.0, outer); + let kept = per_page(&l, |p| p == [1, 0, 0, 0]); + assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}"); +} + +#[test] +fn a_merged_cell_doesnt_stretch_its_cant_split_row() { + // Row 0 can't split; its first cell is merged down into row 1, which can and runs through + // the room. The cut stays in row 1. + let mut nested = Table::new(2, 2, 300.0); + nested.rows[0].props.cant_split = true; + nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart; + nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue; + nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1); + nested.rows[0].cells[1].blocks = one_line_paras("Short", 1); + nested.rows[1].cells[1].blocks = one_line_paras("Long", 20); + let mut outer = Table::new(1, 1, 400.0); + outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()]; + let l = lay_pushed(520.0, outer); + let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]); + assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6bef0c7165..cae60f28b0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -86,6 +86,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 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) | | 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). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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). | 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 nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) 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). | 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) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -230,6 +231,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 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 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | +| 2026-11-03 | trivial | #4 narrowed: a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456); Word shows this for a nested row first in its outer row, the split above a later one is inferred and not yet checked in Word; readiness estimates unchanged | | 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 | | 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing | | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index f1833ce0b6..8f57f85b0a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -65,7 +65,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 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 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692); a cell aligned to the middle or bottom of a split row is aligned within the part left on the page when all its text fits there (as tall as the other cells' text there), and runs from the top of the cell otherwise, as measured in Word (`layout::tests::a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page`); a row whose first part on a page that already holds something would leave a cell empty there, because that cell's first line or inline picture (one line, never split) runs past the room, moves on whole instead of leaving the other cells' text beside the empty cell, so an at-least row grown taller than its minimum by a picture no longer splits off its picture (#4272; inferred from the reporter's Word result and the earlier observation that Word doesn't leave a cell empty for widow control, not yet checked in Word for rows without a minimum; at the top of a page such a row still splits; `layout::tests::an_at_least_row_grown_by_its_picture_moves_on_whole_rather_than_leave_the_picture_behind`, `…_a_picture_row_that_fits_stays_and_text_rows_beside_a_short_picture_still_split`, `…_a_picture_row_too_tall_for_any_page_splits_from_the_top_of_the_next_one`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692); a cell aligned to the middle or bottom of a split row is aligned within the part left on the page when all its text fits there (as tall as the other cells' text there), and runs from the top of the cell otherwise, as measured in Word (`layout::tests::a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page`); a row whose first part on a page that already holds something would leave a cell empty there, because that cell's first line or inline picture (one line, never split) runs past the room, moves on whole instead of leaving the other cells' text beside the empty cell, so an at-least row grown taller than its minimum by a picture no longer splits off its picture (#4272; inferred from the reporter's Word result and the earlier observation that Word doesn't leave a cell empty for widow control, not yet checked in Word for rows without a minimum; at the top of a page such a row still splits; `layout::tests::an_at_least_row_grown_by_its_picture_moves_on_whole_rather_than_leave_the_picture_behind`, `…_a_picture_row_that_fits_stays_and_text_rows_beside_a_short_picture_still_split`, `…_a_picture_row_too_tall_for_any_page_splits_from_the_top_of_the_next_one`); a row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it (docxide-pdf `nested-first-row-*-cant1`) and otherwise splits above it (inferred, not yet checked in Word), and on a fresh page a nested row taller than the room still splits; `layout::tests::nested_cant_split::nested_cant_split_row_moves_on_with_its_outer_row`, `…::the_outer_row_splits_above_a_nested_cant_split_row`, `…::the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it`, `…::the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band`, `…::a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole`, `…::header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first`, `…::a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it`, `…::a_merged_cell_doesnt_stretch_its_cant_split_row`, `…::nested_cant_split_row_taller_than_a_page_still_splits` | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | @@ -324,6 +324,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | +| 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 5c0a62a73669a7bb53a1a470e8dd83bebde8dca4 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:18:09 +0100 Subject: [PATCH 20/47] Layout: no line break between a full stop and an opening parenthesis (#5437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAX #14 allows a break between a full stop (IS) and an opening parenthesis (OP), so `break_positions` (crates/layout/src/para/paragraph.rs) let "29.05.2023г.(оформление ЗУ)" and "2023.(Example detail)" wrap after the full stop. Word keeps such a token whole and wraps it at the space before, as the docxide-pdf dot-open-token* Word references show. The break between the two is now dropped (before the kinsoku pass, so break-anywhere wrapping still applies). Test: layout::tests::a_full_stop_before_a_parenthesis_is_no_break. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/para/paragraph.rs | 3 +++ crates/layout/src/tests.rs | 30 +++++++++++++++++++++++++++++ docs/gaps.md | 2 ++ docs/layout-parity.md | 3 ++- 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 249f287f99..6634121a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,6 +237,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. +- 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. 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. @@ -914,6 +915,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | +| 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index f844e541ab..1d024219d7 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -864,6 +864,9 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec found.sort_unstable(); found.dedup(); } + // UAX #14 breaks between a full stop and an opening parenthesis; Word keeps + // "2023.(Example" whole and wraps it at the space before. + found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('(')))); // Asian typography: kinsoku and breaking Latin words anywhere. crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap); found diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2b1c4eb15b..4acda0f5fe 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13679,6 +13679,36 @@ fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() { panic!("no width in the sweep wrapped the paragraph"); } +/// docxide-pdf `dot-open-token*`: UAX #14 allows a break between a full stop and an opening +/// parenthesis, Word doesn't. The first width that wraps `Deed 2023.(Example` breaks at the space. +#[test] +fn a_full_stop_before_a_parenthesis_is_no_break() { + let text = "Deed 2023.(Example"; + for m in 100..=540 { + let mut d = Document::from_text(text); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| { + if let Placed::Lines { para, l0, l1, .. } = it { + Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::>()) + } else { + None + } + }) + .flatten() + .collect(); + if lines.len() < 2 { + continue; // still fits on one line at this width + } + assert_eq!(lines, ["Deed ", "2023.(Example"], "the first wrap breaks at the space"); + return; + } + panic!("no width in the sweep wrapped the paragraph"); +} + #[test] fn layout_cache_invalidates_on_changed_filename() { let mut doc = Document::new(); diff --git a/docs/gaps.md b/docs/gaps.md index cae60f28b0..e4ed791d1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -87,6 +87,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 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). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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). | 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 nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) 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). | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) 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). | 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) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -232,6 +233,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 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 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | #4 narrowed: a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456); Word shows this for a nested row first in its outer row, the split above a later one is inferred and not yet checked in Word; readiness estimates unchanged | +| 2026-11-03 | trivial | #4 narrowed: a full stop followed by an opening parenthesis stays on one line, as in Word (#5437) | | 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 | | 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing | | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 8f57f85b0a..0034e76638 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -25,7 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — | -| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference)) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference), no break between `.` and `(`) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_full_stop_before_a_parenthesis_is_no_break`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — | @@ -325,6 +325,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | +| 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 4259dd1b2e02dcb310bb98ecc6b6d53c38bf1f6c Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:31:43 +0100 Subject: [PATCH 21/47] Layout: before Word 2013 layout a floating table moved below another keeps its own left edge (#5442) A floating table with tblOverlap=never that is moved below another floating table steps in to its distance from the text's left edge only in Word 2013 layout (compatibility mode 15). clear_of_float_tables read the mode from the legacy shift, the first cell's left margin before mode 15, being zero, so a mode 12 table whose first cell has no left margin was taken for Word 2013 layout and moved 9 pt right (docxide displaced-anchor-*-compat12-overlap0-*: Word 72 pt, WordCraft 81 pt). Pass the mode itself. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/boxes.rs | 20 +++++++++------- crates/layout/src/table_flow.rs | 8 ++++++- crates/layout/src/tests.rs | 42 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6634121a2c..dbbd79d65e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -238,6 +238,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. - 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged. +- 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. 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. @@ -848,6 +849,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193). > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. +> 2026-11-03 · Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin; 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/crates/layout/src/boxes.rs b/crates/layout/src/boxes.rs index c535016f05..0229beebe3 100644 --- a/crates/layout/src/boxes.rs +++ b/crates/layout/src/boxes.rs @@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool { let layout = table::layout_table(ctx, story, t, path, width, depth + 1); let (tx, ty) = match t.props.float { Some(float) => { - let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; + let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t)); let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables); exclusions.push((area, false, None, WrapText::BothSides, true)); float_tables.push(area); @@ -464,14 +464,15 @@ pub(crate) struct BoxLayout { /// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and /// the area text keeps clear of. Positions relative to the page or its margins need the box's -/// `frame`; without one (a table cell) they're relative to the box. +/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's +/// left margin before Word 2013 layout, `None` in it. fn box_float_table_rect( frame: Option, width: f32, y: f32, tl: &table::TableLayout, f: &TableFloat, - legacy: f32, + legacy: Option, float_tables: &[Rect], ) -> (Rect, Rect) { let h = tl.rows.iter().map(|r| r.height).sum::().clamp(0.0, 100_000.0); @@ -507,18 +508,19 @@ fn box_float_table_rect( }; // Nor is there an area to align in: the table starts where the text is. let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align }; - let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 }; - let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables); + let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 }; + let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables); let [dl, dt, dr, db] = f.dist_from_text(); (r, r.inset(-dl, -dt, -dr, -db)) } /// Floating table `f` at `r`, moved below the floating tables in its way when it may not -/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's -/// left edge (`text_left`). -pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect { +/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from +/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first +/// cell has no left margin. +pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect { let [dl, dt, dr, db] = f.dist_from_text(); - let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); + let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); for _ in 0..64 { let area = r.inset(-dl, -dt, -dr, -db); let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break }; diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index 88757e3368..538e9eb746 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -349,7 +349,13 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa let x = float_table_x(pb, w, f, legacy, ctx.mirror); let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) }; obstacles.extend_from_slice(&pb.float_tables); - let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles); + let r = clear_of_float_tables( + Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), + f, + ctx.doc.settings.compat_mode >= 15, + h_area.0, + &obstacles, + ); // Like Word, a table that doesn't fit below the text before it moves to the next page with // its anchor: one placed relative to the paragraph when it would run off the page, one // placed on the page or margin when it would also cover the text already on the page. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4acda0f5fe..784c4a7a69 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7014,6 +7014,48 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() { assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft); } +/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also +/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in +/// the body and in a table cell. +#[test] +fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::TableFloat; + let narrow_x = |mode: u32, in_cell: bool| { + let mut d = Document::from_text("Body."); + d.settings.compat_mode = mode; + let table = |w: f32, text: &str, dist: [f32; 4]| { + let mut t = Table::new(1, 1, w); + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; + t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() }); + t + }; + let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])]; + if in_cell { + let mut outer = Table::new(1, 1, 468.0); + outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect(); + outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default()))); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap(); + } else { + for (i, t) in floats.into_iter().enumerate() { + d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap(); + } + } + let l = lay(&d); + let xs: Vec<(f32, f32)> = + l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect(); + let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") }; + assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}"); + nx - wx + }; + for in_cell in [false, true] { + let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell)); + assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one"); + assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one"); + } +} + /// A margin-anchored floating table is placed from the anchor page's margin even when a body /// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every /// table row is drawn exactly once there. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0034e76638..bb08fdadee 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -69,6 +69,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | +| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; one with `tblOverlap=never` moved below another keeps its distance from the text's left edge only in Word 2013 layout (mode 15), before it its own left edge, also when its first cell has no left margin (docxide `displaced-anchor-*`); other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break stays on the page before (a cover page, #1632). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break, with nothing after it that takes room (other anchors, bookmarks, comment or field marks), stays on the page before (a cover page, #1632); with anything after it that takes room (text, a space or tab, an inline picture, another break), it is on the new page with it (observed in Word for Mac: a shape dragged beside the first line after a page break inside a paragraph is saved anchored right after the break, positioned from the paragraph's top on the new page, and stays there, #4262, `layout::tests::a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page`). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | @@ -286,6 +287,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc > 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`). > 2026-11-01 · An in-line picture, shape or chart is raised or lowered by its run's position (Word's `w:position`, Font › Advanced › Position): lowered, it reaches below the baseline and the line grows down to hold it (at most until its top is on the baseline); raised, it stands clear of the baseline. It used to stand on the baseline whatever the position (`layout::tests::an_inline_picture_moves_with_its_runs_position`). +> 2026-11-03 · Before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge instead of stepping in by its distance from the text, also when its first cell has no left margin (`layout::tests::a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout`). > 2026-11-01 · Paragraph side borders lie beyond their distance from the text, as the top and bottom ones do; top and bottom reach the corners and the shading fills out to the outer edges (#5036) (`layout::tests::side_borders_lie_beyond_their_distance_from_the_text`). @@ -326,6 +328,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | +| 2026-11-03 | trivial | Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin (the mode was read from that margin being zero); readiness estimates unchanged | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 5dfb3acf1511e14d4f3c04975579063859625078 Mon Sep 17 00:00:00 2001 From: Kevin Joseph <47004376+kevinj24fr@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:51 +0100 Subject: [PATCH 22/47] picture.change: keep the width and take the new picture's aspect ratio (#5761) * picture.change: keep the width and take the new picture's aspect ratio Change Picture swapped only the media key, so a replacement of another shape was stretched into the old frame (a revised figure came out ~2% taller in a real manuscript). Keep the display width, set the height from the new picture's pixel size, and drop the old crop, which described the old picture. Regression test included. * docs: progress log and parity entries for picture-change-aspect --------- Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/engine/src/cmd/objects/specs.rs | 22 +++++++++++---- crates/engine/src/tests.rs | 38 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbbd79d65e..047b50e138 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-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-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) +- 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. - 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. @@ -870,6 +871,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) | | 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | +| 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | | 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/engine/src/cmd/objects/specs.rs b/crates/engine/src/cmd/objects/specs.rs index dfc3cc76a7..11967ed4e5 100644 --- a/crates/engine/src/cmd/objects/specs.rs +++ b/crates/engine/src/cmd/objects/specs.rs @@ -132,14 +132,26 @@ pub fn specs() -> Vec { } else { return Err(CmdError::Params("`path` or `data` required".into())); }; - wordcraft_geom::picture::probe(&bytes).map_err(|e| match e { - wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()), - _ => CmdError::Failed("not a supported image".into()), - })?; + let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes) + .map(|p| (p.width, p.height)) + .map_err(|e| match e { + wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()), + _ => CmdError::Failed("not a supported image".into()), + })?; let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes)); with_obj(s, |o| { - if let InlineObject::Image { media, .. } = o { + if let InlineObject::Image { media, w, h, crop, .. } = o { *media = key.clone(); + // The replacement keeps the picture's width and takes its own aspect ratio, so a + // picture of another shape isn't stretched into the old frame. The old crop + // described the old picture, so it is dropped. + if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 { + let nh = *w * px_h as f32 / px_w as f32; + if nh.is_finite() && nh > 0.0 { + *h = nh; + } + } + *crop = [0.0; 4]; } }) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2718ef8de6..3b5822dced 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10817,6 +10817,44 @@ fn picture_change_keeps_the_replacement_format() { let _ = std::fs::remove_dir_all(&dir); } +/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for +/// a revised one of a different shape must not stretch it into the old frame. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_width_and_takes_new_aspect() { + let dir = scratch_dir("picture-change-aspect"); + let mut wide = Vec::new(); + image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let mut tall = Vec::new(); + image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); + let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); + std::fs::write(&first, &wide).unwrap(); + std::fs::write(&second, &tall).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + let size = |s: &Session| { + let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { + out = (*w, *h, *crop); + } + }); + out + }; + let (w0, h0, _) = size(&s); + assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); + run(&mut s, "picture.crop", json!({"left": 0.1})); + let (w_cropped, _, crop) = size(&s); + assert!(crop[0] > 0.0, "cropped before the change"); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let (w1, h1, crop) = size(&s); + assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); + assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); + assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn json_open_repairs_hostile_run_lengths() { use wordcraft_doc::{Block, Document}; From 3291be0fbf02ecc7eafcf8473c6f2d83e3bd20cd Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:45:17 +0100 Subject: [PATCH 23/47] Layout: an empty paragraph after a leading floating table takes no space before (#5440) Word drops the space before of an empty paragraph that follows a document's leading floating table: its PDFs of docxide-pdf's leading-empty-before-float-0 and -12 put the text after the empty anchor (beside the table) at the same place for 0 pt and 12 pt before. Flow::gap_before (crates/layout/src/flow.rs) kept it: a floating table takes no room in the flow and clears `pb.prev`, so the paragraph after it was laid out like a document's first paragraph, with its full space before. It now drops it for body block 1 when block 0 was placed floating on this page (the page's float_tables), the page is fresh and the paragraph holds nothing but markers (bookmarks, comment and permission ranges). It doesn't check that the paragraph sits beside the table, so below a full-width leading float an empty paragraph loses its space before too; that case is untested. Later pages and columns, and paragraphs with text, keep their space before. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/flow.rs | 11 ++++++ crates/layout/src/tests.rs | 1 + crates/layout/src/tests/float_top.rs | 53 ++++++++++++++++++++++++++++ docs/layout-parity.md | 2 ++ 5 files changed, 69 insertions(+) create mode 100644 crates/layout/src/tests/float_top.rs diff --git a/ROADMAP.md b/ROADMAP.md index 047b50e138..5e09190eea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. - 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged. - 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. Readiness unchanged. +- 2026-11-03: Layout: an empty paragraph after a document's leading floating table takes no space before, as in Word's PDFs of docxide-pdf's `leading-empty-before-float` cases (beside the table); below a full-width one it loses it too, which is untested. 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. @@ -920,6 +921,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | | 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis | +| 2026-11-03 | trivial | Progress log: an empty paragraph after a document's leading floating table takes no space before | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 5fd30ff997..233add4ef0 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -281,6 +281,17 @@ impl<'a> Flow<'a> { if self.pl.rp.before_auto && self.block == 0 { before = 0.0; } + // An empty paragraph after a document's leading table, placed floating on this page, has no + // space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place). + // Only that shape is observed: later pages and columns keep theirs. + if self.block == 1 + && pb.fresh() + && !pb.web + && pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0))) + && only_markers(self.p, 0..self.p.text.len()) + { + before = 0.0; + } before } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 784c4a7a69..cc5df29f05 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -9,6 +9,7 @@ mod bidi; mod budget; mod cell_floats; mod complex; +mod float_top; mod grid; mod ligature; pub(crate) mod linear; diff --git a/crates/layout/src/tests/float_top.rs b/crates/layout/src/tests/float_top.rs new file mode 100644 index 0000000000..fb555de219 --- /dev/null +++ b/crates/layout/src/tests/float_top.rs @@ -0,0 +1,53 @@ +//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs +//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at +//! the same place for 0 pt and 12 pt before. + +use super::lay; +use crate::{Arc, Placed, StoryRef}; +use wordcraft_doc::para::{Anchor, InlineObject}; +use wordcraft_doc::props::TableFloat; +use wordcraft_doc::{Block, Document, Paragraph, Table, para_block}; + +/// Where the lines of body block `block` start on page one. +fn line_y(d: &Document, block: u32) -> f32 { + lay(d).pages[0] + .items + .iter() + .find_map(|i| match i { + Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y), + _ => None, + }) + .unwrap() +} + +/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After". +fn document(table: bool, text: &str, before: f32) -> Document { + let mut p = Paragraph::with_text(text, Default::default()); + // "#" stands for a bookmark, a zero-width marker. + if text == "#" { + p = Paragraph::new(); + p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap(); + } + p.props.space_before = Some(before); + let mut d = Document::new(); + d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))]; + if table { + let mut t = Table::new(1, 1, 200.0); + t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))]; + t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() }); + d.body.insert(0, Arc::new(Block::Table(t))); + } + d +} + +#[test] +fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() { + let empty = line_y(&document(true, "", 12.0), 2); + let plain = line_y(&document(false, "", 0.0), 1); + assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}"); + // A bookmark alone doesn't make it a paragraph with text. + assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01); + // One with text keeps it, and so does an empty first paragraph with no table before it. + assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99); + assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index bb08fdadee..942c87e8af 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -70,6 +70,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; one with `tblOverlap=never` moved below another keeps its distance from the text's left edge only in Word 2013 layout (mode 15), before it its own left edge, also when its first cell has no left margin (docxide `displaced-anchor-*`); other overlap rules untested | 2–3 | +| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested; an empty paragraph (or one of bookmarks and other markers only) after a document's leading floating table takes no space before (docxide-pdf's `leading-empty-before-float` Word PDFs, the paragraph beside the table); below a full-width one it loses it too, untested; a paragraph with text below a full-width one keeps it (`connector-paragraph-anchor-0`/`-240`); one with text beside a narrow one, and later pages and columns, are untested and keep it | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break stays on the page before (a cover page, #1632). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break, with nothing after it that takes room (other anchors, bookmarks, comment or field marks), stays on the page before (a cover page, #1632); with anything after it that takes room (text, a space or tab, an inline picture, another break), it is on the new page with it (observed in Word for Mac: a shape dragged beside the first line after a page break inside a paragraph is saved anchored right after the break, positioned from the paragraph's top on the new page, and stays there, #4262, `layout::tests::a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page`). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | @@ -329,6 +330,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | | 2026-11-03 | trivial | Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin (the mode was read from that margin being zero); readiness estimates unchanged | +| 2026-11-03 | trivial | An empty paragraph after a document's leading floating table takes no space before, as Word's PDFs of docxide-pdf's `leading-empty-before-float` cases show (the paragraph beside the table); below a full-width one it loses it too, untested; paragraphs with text, later pages and columns keep it (`layout::tests::float_top`) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 52934ef11073786d677980ffc79692cfbea65921 Mon Sep 17 00:00:00 2001 From: Senura Dissanayake Date: Sun, 8 Nov 2026 05:45:35 +0100 Subject: [PATCH 24/47] docbin: brace and bracket shapes are unfilled unless the file fills them (#5943) (#5945) A .doc bracket or brace AutoShape (MSOSPT 85-88, 185, 186) is drawn as its box, and with no fFilled property it got the default white fill, an opaque block over the text behind it. Word's shapetypes for these shapes are unfilled by default (VML filled="f"), so they now get a fill only when fFilled is set. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docbin/src/drawing.rs | 6 ++++- crates/docbin/src/tests/drawing.rs | 35 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5e09190eea..64000e722f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -385,6 +385,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) | | 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) | | 2026-10-31 | A picture or shape dropped beside the first line after a page break inside a paragraph stays on the new page, as in Word, instead of going back to the page before the break (#4262) | +| 2026-11-07 | A brace or bracket shape in a `.doc` file is drawn without a fill unless the file gives it one, as in Word, instead of as a white box that hides the text behind it (#5943) | | 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | | 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | @@ -905,6 +906,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) | | 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) | | 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) | +| 2026-11-07 | trivial | Progress log: `.doc` brace and bracket shapes unfilled by default (#5943) | | 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. | | 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output | | 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged | diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index 4fe3b62a6a..33f45de345 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) - kind = ShapeKind::TextBox; } let line = kind == ShapeKind::Line; - let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) }; + // Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless + // `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are + // drawn as their box here, which a default fill would make an opaque block over the text. + let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true)); + let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) }; let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) }; // Line widths are EMUs; Word's default is 0.75 pt. let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0)); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index ae9ec9e3fe..dc18c3d372 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() { assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); } +#[test] +fn braces_and_brackets_are_unfilled_unless_filled_is_set() { + // A right brace (88) in front of the text with no fill properties, and a left bracket (85) + // whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default + // white fill. + let brace = sp(&[fsp(88, 1026, 0xA00)]); + let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]); + let rect = sp(&[fsp(1, 1028, 0xA00)]); + let art = art_content(&[], &[brace, bracket, rect]); + let flags = spa_flags(0, 0, 3, false); + let spas = + plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat()); + let text = "A\u{8}\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let fills: Vec<_> = p0 + .objects + .iter() + .map(|o| match o { + InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + fills, + [ + (None, Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + ] + ); +} + #[test] fn text_boxes_pointing_at_the_same_story_text_read_it_once() { // Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4dc5e6d178..cd74be849e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.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) | | `.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`, `strict_drawings` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329); shapes, text boxes, groups and canvases (`wsp`, `wgp`, `wpc` in the Strict drawing namespace, a text box's `wne:txbxContent` or `txbxContent`) read as their Word 2010 names and are saved as Transitional (#3548). Charts and SmartArt in Strict namespaces are not mapped | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.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. | +| `.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]); brackets and braces are drawn as their box, unfilled unless the file fills them (#5943). 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 | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, RTL | @@ -360,6 +360,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-04 | trivial | ODT: a named section inside a table's cell (StarOffice's specification template hides some there) is written as read around the cell's paragraphs, closed in the cell; one starting or ending at a paragraph with twins elsewhere (an empty one, as in LibreOffice's agenda templates) is kept too, by that paragraph's own identity, and opened once whatever is copied. Over the corpus 126 of 133 files round-trip pixel-identical (124); over the 220 from other applications 176 (175), 218 with the same page count (216). | | 2026-11-04 | trivial | ODT element audit ([odt-element-audit.md](odt-element-audit.md)): every element of the ODF 1.3 schema against two corpora (365 files) and a document of our own for each a text document can hold that no file did. Found: index marks over text, plugins and floating frames weren't kept, a form's data source was taken for a control, and password, number, time and grid form controls were dropped; all kept now. | | 2026-11-04 | trivial | ODT, found in StarOffice's long specifications: a reference mark (§6.6.2 `text:reference-mark`, which `text:reference-ref` names) was written as a bookmark only, so references to it found nothing once its paragraph was written from the model; it is written as both (a reference mark over its text when it ends in the paragraph, else at a point). Lists without an `xml:id` (older files') are kept as read too, given one. | +| 2026-11-07 | trivial | `.doc`: bracket and brace shapes (MSOSPT 85–88, 185, 186) are unfilled unless `fFilled` is set, the default of Word's shapetypes for them, so the box they are drawn as no longer hides the text behind it (#5943) | | 2026-11-04 | trivial | ODT, found checking 220 files from Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony and others: a section hidden with `text:display="none"` showed its text once written without the section (ending in a list, or in a table's cell); what a hidden section holds is now hidden text, so it stays hidden whatever is written, and WordCraft hides it too. A top-level list whose items nobody edited is written as read whole (a label followed by a space, picture bullets, an unnumbered header: what a paragraph's own markup can't say), a list going on with it naming it; a section may begin or end with a list, which then breaks there. A damaged package's media type isn't shown in its error. Of the 220, 166 round-trip pixel-identical and 194 with the same text. | | 2026-11-03 | trivial | ODT, found checking files from other applications: a kept package part without a media type (LibreOffice's empty `Configurations2/accelerator/current.xml`, another application's file the manifest didn't list) was written without a manifest entry, and LibreOffice refused to open the saved file; every file written has its entry now (ODF 1.3 Part 2 §4.3), its media type empty as read. | | 2026-11-03 | trivial | ODT: more paragraphs written back as read while unchanged: those holding bookmarks (their names kept, so a bookmark may start in markup as read and end in a paragraph written from the model; each name written once), footnotes and endnotes (each note's text fingerprinted when read; while the note numbering is as read, as the citation is its number), formulas, charts and other embedded objects (with their files and preview pictures, only while the paragraph showing them is written as read), and those page-anchored frames LibreOffice puts in the text before a paragraph join (their markup kept before the paragraph's). Styles of different families with one name (LibreOffice's paragraph and graphic "Marginalia") are kept apart, as list styles were. The text keeps `text:use-soft-page-breaks` while markup kept as read holds soft page breaks. Still written from the model: form controls (named afresh in the forms), and a chart's paragraph (WordCraft draws the chart after reading it). | From 4cd6ffa5648879ccc45b57877afa7e04803b0bf1 Mon Sep 17 00:00:00 2001 From: David Prieto Date: Sun, 8 Nov 2026 05:48:03 +0100 Subject: [PATCH 25/47] doc: preserve following formatting when deleting an empty paragraph (#5929) * doc: preserve following formatting when deleting an empty paragraph (Leaf #152) * doc: regress empty paragraph merge formatting (Leaf #152) * engine: cover empty heading deletion, history and DOCX (Leaf #152) * typing: pin empty heading deletion semantics (Leaf #152) * docs: record empty paragraph formatting coverage (Leaf #152) * docs: record the empty paragraph deletion fix (Leaf #152) * test: retain structured controls across an empty paragraph join * docs: explain empty paragraph engine regressions * docs: explain the empty heading keyboard regression --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/doc/src/edit.rs | 11 +- crates/doc/tests/empty_paragraph_merge.rs | 158 +++++++++++++++++++ crates/engine/src/tests/typing.rs | 13 ++ crates/engine/tests/empty_paragraph_merge.rs | 80 ++++++++++ docs/gaps.md | 1 + docs/typing-parity.md | 10 ++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 crates/doc/tests/empty_paragraph_merge.rs create mode 100644 crates/engine/tests/empty_paragraph_merge.rs diff --git a/ROADMAP.md b/ROADMAP.md index 64000e722f..61ab68bcbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,6 +357,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 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-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | +| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. | | 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 | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 0a869ee70a..fc8fa2bb26 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -772,6 +772,10 @@ impl Document { if a.path.parent() == b.path.parent() { // Same container: trim the ends, drop the blocks between, join. let (ia, ib) = (a.path.last(), b.path.last()); + // Removing an already-empty paragraph must not impose its style or mark formatting + // on the following content. Test before trimming: deleting actual heading text is + // still an ordinary merge that keeps the first paragraph's properties. + let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty); // Kept markers of the paragraphs in between move to the join. let mut moved: Vec<(InlineObject, CharProps)> = Vec::new(); // `kept` is sorted: one walk of each paragraph's objects and runs (#3614). @@ -819,15 +823,18 @@ impl Document { if ib < bl.len() && ia < ib { bl.drain(ia + 1..=ib); } - tail.props = ParaProps::default(); // The deleted marks include `a`'s, so a section break there goes too: the joined // paragraph ends where `b`'s mark did, and its text takes the following section's // setup, as in Word (#2127). let sect = tail.section.take(); let pa = self.para_mut(a.story, &a.path)?; - let keep_props = pa.props.clone(); + let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() }; + let keep_mark = empty_start.then(|| tail.mark.clone()); pa.append(tail); pa.props = keep_props; + if let Some(mark) = keep_mark { + pa.mark = mark; + } let before = std::mem::replace(&mut pa.section, sect); if a.story == crate::StoryRef::Body && a.path.0.len() == 1 { // Nearest first, as "link to previous" resolved them before the delete. diff --git a/crates/doc/tests/empty_paragraph_merge.rs b/crates/doc/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..7368ad1be1 --- /dev/null +++ b/crates/doc/tests/empty_paragraph_merge.rs @@ -0,0 +1,158 @@ +use std::sync::Arc; +use wordcraft_doc::props::{Align, CharProps, ParaProps}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block}; + +/// Build an empty heading followed by explicitly formatted, mixed-run body text. +fn fixture(first: &str, style: Option<&str>) -> Document { + let mut d = Document::from_text(&format!("{first}\nBody paragraph.")); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Heading1".into()); + p.props.outline_level = Some(0); + p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.props = ParaProps { + style: style.map(str::to_string), + align: Some(Align::Right), + indent_left: Some(18.0), + space_after: Some(7.0), + keep_next: Some(false), + ..Default::default() + }; + p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() }; + p.format(0, 4, &|c| c.italic = Some(true)).unwrap(); + d +} + +/// Compare paragraph, mark and inline-run formatting independently of text. +fn assert_format(p: &Paragraph, expected: &Paragraph) { + assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties"); + assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark"); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); +} + +/// Deleting an empty paragraph preserves the following style, including implicit Normal. +#[test] +fn deleting_an_empty_paragraph_keeps_the_following_format() { + for first_style in ["Heading1", "Heading3", "Normal"] { + for style in [None, Some("Normal"), Some("Heading2")] { + let mut d = fixture("", style); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into()); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0)); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text); + assert_format(p, &expected); + } + } +} + +/// A selection spanning several paragraphs keeps the surviving tail format in both directions. +#[test] +fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() { + for backwards in [false, true] { + let mut d = fixture("", None); + d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2"))); + let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone(); + expected.delete(0, 5).unwrap(); + let (a, b) = (Pos::body(0, 0), Pos::body(2, 5)); + d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap(); + assert_eq!(d.body.len(), 1); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "paragraph."); + assert_format(p, &expected); + } +} + +/// A control whose closing marker survives must move across the join without replacing tail formatting. +#[test] +fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() { + use wordcraft_doc::control::ContentControl; + use wordcraft_doc::{InlineObject, Revision, RevisionKind}; + + let mut d = fixture("", Some("Normal")); + d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default()))); + d.body.push(para_block(Paragraph::with_text("After", Default::default()))); + d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() }); + d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() }); + let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap(); + p.mark.ins = Some(0); + p.mark.del = Some(1); + let expected = p.clone(); + let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() }; + d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap(); + d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap(); + assert_eq!(d.control_ranges(StoryRef::Body).len(), 1); + + d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap(); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker"); + assert_eq!(p.props_of_char(0), &marker); + assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }])); + assert_eq!(p.text, format!("\u{fffc}{}", expected.text)); + let ranges = d.control_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, Pos::body(0, 0)); + assert_eq!(ranges[0].end, Pos::body(1, 5)); + assert!(!d.has_unbalanced_controls()); +} + +/// The empty-paragraph rule applies in header stories and table-cell containers. +#[test] +fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() { + for cell in [false, true] { + let mut d = fixture("", Some("Normal")); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let (story, a, b) = if cell { + let mut table = Table::new(1, 1, 300.0); + table.rows[0].cells[0].blocks = d.body.clone(); + d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())]; + (StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1])) + } else { + let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone()); + (StoryRef::Part(id), Path::top(0), Path::top(1)) + }; + d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap(); + assert_format(d.para(story, &a).unwrap(), &expected); + } +} + +/// Absorbing an empty section keeps the following format and inherited header. +#[test] +fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() { + let mut d = fixture("", None); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]); + let mut section = d.last_section.clone(); + section.headers.default = Some(header); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section)); + d.last_section.margin_left = 96.0; + d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(); + assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected); + assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none()); + assert_eq!(d.last_section.headers.default, Some(header)); + assert_eq!(d.last_section.margin_left, 96.0); +} + +/// Deleting actual text continues to use the first paragraph merge rule. +#[test] +fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() { + for off in [0, 7] { + let mut d = fixture("Heading", None); + let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone(); + d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap(); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); + } +} + +/// Removing a soft line break must not change paragraph formatting. +#[test] +fn a_soft_break_is_not_an_empty_paragraph_merge() { + let mut d = fixture("", None); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.insert_text(0, "\nHeading", &Default::default()).unwrap(); + let expected = p.props.clone(); + d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap(); + assert_eq!(d.body.len(), 2); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 01ab762111..e2594492dc 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -9,6 +9,19 @@ use wordcraft_doc::{Pos, StoryRef}; use crate::Session; +/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. +/// Deleting the empty heading mark keeps the next paragraph as body text. +#[test] +fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { + let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + run(&mut s, "text.delete", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "Body paragraph."); + assert_eq!(p.props.style, None); + assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); +} + /// Create an empty session for command-driven typing fixtures. fn s() -> Session { Session::new(wordcraft_doc::Document::new()) diff --git a/crates/engine/tests/empty_paragraph_merge.rs b/crates/engine/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..40f81088c5 --- /dev/null +++ b/crates/engine/tests/empty_paragraph_merge.rs @@ -0,0 +1,80 @@ +use serde_json::{Value, json}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; +use wordcraft_engine::{Selection, Session}; +use wordcraft_layout::outline; + +/// Dispatch an engine command and report its ID if it fails. +fn run(s: &mut Session, id: &str, params: Value) { + s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +/// Create an empty heading, mixed-run body text and a genuine TOC heading. +fn fixture() -> Session { + let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark = + CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() }; + p.format(5, 14, &|c| c.italic = Some(true)).unwrap(); + s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into()); + s +} + +/// Keyboard and selection deletes preserve body formatting through Undo and Redo. +#[test] +fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() { + for mode in ["delete", "backspace", "selection", "reverse selection"] { + for pending in [false, true] { + let mut s = fixture(); + let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone(); + s.sel = match mode { + "backspace" => Selection::caret(Pos::body(1, 0)), + "selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) }, + "reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) }, + _ => Selection::caret(Pos::body(0, 0)), + }; + if pending { + s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() }); + } + let before = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text, "{mode}, pending={pending}"); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); + let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); + let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0)); + assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic)); + assert!(s.pending.is_none()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let after = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after); + } + } +} + +/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline. +#[test] +fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() { + let mut s = fixture(); + run(&mut s, "text.delete", json!({})); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), None); + assert_eq!(p.mark.size, Some(11.0)); + assert_eq!(p.props_of_char(6).italic, Some(true)); + s.sel = Selection::caret(Pos::body(2, 0)); + run(&mut s, "references.toc", json!({"levels": 3})); + let text = s.doc.plain_text(StoryRef::Body); + assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}"); + assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index e4ed791d1b..55f8757d42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,6 +204,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Picture hyperlinks show on hover, follow on Ctrl/⌘+click and round-trip as Word stores them (#5267); 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-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate 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/typing-parity.md b/docs/typing-parity.md index 266d9cf9a4..e1b2096742 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -584,11 +584,21 @@ command is one Undo step and still obeys editing protection and the 1–1638 pt limits. Original-fixture engine and Linux CLI/headless UI save/reopen checks cover this correction; native Windows interaction and Microsoft Word remain untested. +## Empty paragraph removal + +Deleting a paragraph mark whose preceding paragraph was already empty preserves the +following paragraph's paragraph properties and mark formatting. Existing inline runs +remain intact. Delete, Backspace and selected marks share the model operation; joining +two originally non-empty paragraphs retains the first paragraph's properties. Synthetic +regressions cover body, header and cell containers, sections, outline/TOC membership, +DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run. + ## Revision history | 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-11-07 | trivial | Removing an already-empty paragraph preserves the following paragraph properties, mark and inline formatting. Delete, Backspace, selected marks, outline/TOC, DOCX and Undo/Redo have synthetic coverage (Leaf #152); no new Word or native acceptance run. | | 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-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) | | 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) | From f0fb31c0c97cb31c402c8ba40ab48b26b0a828fa Mon Sep 17 00:00:00 2001 From: Jhong-Ken Chen Date: Sun, 8 Nov 2026 12:48:20 +0800 Subject: [PATCH 26/47] =?UTF-8?q?XML:=20normalise=20line=20ends=20in=20tex?= =?UTF-8?q?t=20and=20CDATA=20(XML=201.0=20=C2=A72.11)=20(#5897)=20(#5898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text nodes were decoded without line-end normalisation, so a file saved with Windows line ends kept the CR of each CR LF: a line break inside w:t read as two spaces. Text and CDATA now go through quick-xml's xml_content, which reads CR LF and a lone CR as one line feed; a CR written as stays a CR. Attribute values were already normalised. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docx/src/xml.rs | 33 ++++++++++++++++++++++++++++++-- crates/docx/tests/it/word_xml.rs | 9 +++++++++ docs/file-format-parity.md | 5 +++-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61ab68bcbc..ce5a54e177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log - 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. +- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged. - 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. @@ -865,6 +866,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | | 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. | diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index f14991673a..ca12fa5d63 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -462,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res attach(&mut stack, &mut root, el); } } + // Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as + // LF, so a file saved with Windows line ends has no CR in its text. A CR written as + // ` ` is a character reference and stays. Event::Text(t) => { if let Some(top) = stack.last_mut() { - let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?; + let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?; push_text(top, &s); } } Event::CData(t) => { if let Some(top) = stack.last_mut() { - let s = String::from_utf8_lossy(&t).into_owned(); + // CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way. + let s = t + .xml_content(XmlVersion::Explicit1_0) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n")); push_text(top, &s); } } @@ -724,6 +731,28 @@ mod tests { assert_eq!(t.attr("xml:space"), Some("preserve")); } + /// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised. + #[test] + fn line_ends_in_cdata_that_isnt_utf8_are_normalised() { + let x = + b""; + assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc"); + } + + /// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR. + #[test] + fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() { + let x = b"a\r\nb\rc d"; + let e = parse(x).unwrap(); + assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf"); + // The writer escapes a CR, so a CR in the text survives a save. + let x = format!( + "{}", + esc("a\r\nb\rc") + ); + assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc"); + } + #[test] fn choice_requirements_resolve_namespace_aliases_and_rebindings() { let x = br#""#; diff --git a/crates/docx/tests/it/word_xml.rs b/crates/docx/tests/it/word_xml.rs index dd614ae59f..99b05e865c 100644 --- a/crates/docx/tests/it/word_xml.rs +++ b/crates/docx/tests/it/word_xml.rs @@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() { // A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000). assert!(limited(&format!(r#"{}"#, "".repeat(8_000_000)))); } + +/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML +/// 1.0 §2.11), so a line break inside `w:t` is one space, not two. +#[test] +fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() { + let xml = "\r\n\r\na\r\nb\r\n"; + let d = read_xml(xml.as_bytes()).unwrap(); + assert_eq!(paras(&d)[0].plain_text(), "a b"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cd74be849e..b8296f0511 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -4,7 +4,7 @@ > **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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (XML line ends in text normalised, #5897); previously 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. @@ -84,7 +84,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | -| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | +| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Line ends in text are normalised as XML 1.0 §2.11 says, so CR LF inside `w:t` is one line feed (#5897, `word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break`). Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | `.xlsx`/`.xlsm`/`.xls`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell, 64 MiB of cell text a sheet so a shared string repeated in every cell can't exhaust memory, #1287). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text; an HTML page or XML spreadsheet named `.xls` gets a message saying which it is (#1287). Text lists (.csv/.tsv/.txt) infer comma, semicolon or tab from the complete first record, ignoring quoted punctuation, doubled quotes and quoted line breaks. They decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources | | Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 1–2 | `crates/docx/src/crypt.rs` (9 unit tests), `tests/malformed.rs`, `crates/engine/src/tests.rs::password_protected_documents_round_trip`, `aes_encrypted_packages_open_under_any_word_extension` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Standard encryption (Word 2007: AES-128/192/256-ECB, SHA-1, 50,000 spins) opens too, and either kind opens when named `.doc`/`.dot` (gap 9); saving stays agile. Synthetic files only; never checked against Word-made protected files | @@ -334,6 +334,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897) | | 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. | From b8a7e2acdb3ccdb9b4a9d4f17410b1d10cd29c05 Mon Sep 17 00:00:00 2001 From: neilcawse Date: Sat, 7 Nov 2026 23:50:41 -0500 Subject: [PATCH 27/47] docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph (#5952) * docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph * test(docx): assert TOC entries survive round-trip in section-break TOC control test --- crates/docx/src/read/toc.rs | 7 +-- crates/docx/tests/it/content_controls.rs | 54 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs index 1c246732a2..3516044616 100644 --- a/crates/docx/src/read/toc.rs +++ b/crates/docx/src/read/toc.rs @@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) { } // An empty paragraph opening the control takes the field. It may end a section, as above // entries in columns (#3889). - if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) { + let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()); + if empty_heading { field_to_heading(&mut engine, s + 1); } else { toc_field_to_heading(&mut engine); } if crate::write::written_toc_span(&engine) == Some((s, last)) { - *body = toc; + *body = if empty_heading { toc } else { engine }; } } @@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) { /// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles. pub(super) fn toc_field_to_heading(body: &mut Blocks) { for i in 1..body.len() { - if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) { + if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) { field_to_heading(body, i); } } diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs index 63b79c4c4f..ef420e42d8 100644 --- a/crates/docx/tests/it/content_controls.rs +++ b/crates/docx/tests/it/content_controls.rs @@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() { assert!(xml.rfind("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); } } + +#[test] +fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() { + let style = |s: &str, inner: &str| format!(r#"{inner}"#); + let begin = r#" TOC \o "1-3" \h \z \u "#; + let content = format!( + "{}{}{}", + style("TOCHeading", &t("Contents")), + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")) + ); + let pr = r#""#; + let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One")))); + for pass in 0..2 { + let d = read(&bytes).unwrap(); + bytes = write(&d).unwrap(); + let xml = document_xml(&bytes); + assert_eq!(xml.matches("").count(), 1, "pass {pass}: {xml}"); + assert!(xml.contains(r#""#), "pass {pass}: {xml}"); + assert!(xml.find("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); + } +} + +#[test] +fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() { + let style = |s: &str, inner: &str| format!(r#"{inner}"#); + let begin = r#" TOC \o "1-3" \h \z \u "#; + // A range TOC that was never in a content control (with FieldEnd in its own paragraph) must + // not gain a Table of Contents w:sdt on save. + let bare = format!( + "{}{}{}{}", + style("TOCHeading", &t("Contents")), + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")), + p(&t("Chapter One")), + ); + let d_bare = read(&package(&bare)).unwrap(); + let xml_bare = document_xml(&write(&d_bare).unwrap()); + assert_eq!(xml_bare.matches("").count(), 0, "{xml_bare}"); + + // #3889: a Table of Contents control opening on an empty paragraph that ends a section above + // the entries hands its control over on read so Update Table reuses the section-break paragraph. + let sect_toc = format!( + r#"{}{}"#, + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")), + ); + let pr = r#""#; + let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap(); + assert!(controls(&d_sect).is_empty()); + assert!(d_sect.body[0].as_para().unwrap().section.is_some()); + let xml_sect = document_xml(&write(&d_sect).unwrap()); + assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}"); +} From de4ca293097a70e04c79eb39185dbb591d33a2f8 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:53:01 -0500 Subject: [PATCH 28/47] wordcraft-web: window.wordcraft, the page command API from craft-web-api (#5860) The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages. --- Cargo.lock | 14 +++++++- apps/wordcraft-web/Cargo.toml | 4 ++- apps/wordcraft-web/src/api.rs | 61 ++++++++++++++++++++++++++++++++++ apps/wordcraft-web/src/main.rs | 2 ++ apps/wordcraft-web/src/web.rs | 3 +- packaging/web/README.md | 12 +++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 apps/wordcraft-web/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index 0e5395268c..91c94ce265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,10 +1158,21 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -7098,6 +7109,7 @@ dependencies = [ name = "wordcraft-web" version = "0.15.1" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 971daf0cbd..8c42c988b1 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" wordcraft-engine = { workspace = true } wordcraft-ui-egui = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } +# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/wordcraft-web/src/api.rs b/apps/wordcraft-web/src/api.rs new file mode 100644 index 0000000000..e0dbfed200 --- /dev/null +++ b/apps/wordcraft-web/src/api.rs @@ -0,0 +1,61 @@ +//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the +//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`). +//! +//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, +//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that +//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the +//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs +//! when the app reaches it, as it does for a desktop client that timed out. + +use std::rc::Rc; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; +use serde_json::{Value, json}; +use wasm_bindgen_futures::JsFuture; +use wordcraft_ui_egui::ControlRequest; + +/// The app id in `window.`. +const APP: &str = "wordcraft"; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +const POLL_MS: i32 = 16; + +/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to +/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has. +pub fn install(ctx: egui::Context) -> Receiver { + let (queue, requests) = channel::(); + let request: Request = Rc::new(move |method, params| { + let (control, reply) = ControlRequest::new(method, params); + if queue.send(control).is_err() { + return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) }); + } + ctx.request_repaint(); + Box::pin(wait(reply)) + }); + if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() { + log::warn!("couldn't install window.{APP}: {e:?}"); + } + requests +} + +/// The app's reply, polled about once a frame. +async fn wait(reply: Receiver) -> Value { + loop { + match reply.try_recv() { + Ok(reply) => return reply, + Err(TryRecvError::Empty) => sleep(POLL_MS).await, + Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + } + } +} + +async fn sleep(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = JsFuture::from(promise).await; +} diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs index eb24ece139..285ce2e435 100644 --- a/apps/wordcraft-web/src/main.rs +++ b/apps/wordcraft-web/src/main.rs @@ -20,6 +20,8 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")] diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a6bb8a8990..fb808decf8 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -76,7 +76,8 @@ pub fn start() { if let Some(bridge) = &app_bridge { bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone()); } - let mut app = WordApp::new(session, services); + let control = crate::api::install(cc.egui_ctx.clone()); + let mut app = WordApp::new(session, services).with_control(control); // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349). app.services.pasted = Some(pasted); app.autosave = false; diff --git a/packaging/web/README.md b/packaging/web/README.md index 07b3ba4ff9..5a56fdb184 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -159,6 +159,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h - **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. - **The event and message names are the interface.** `wordcraft:ready`, `wordcraft:open`, `wordcraft:save`, `wordcraft:dirty` and `wordcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. +### Driving it from the page (`window.wordcraft`) + +Every WordCraft web build puts `window.wordcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `wordcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects. WordCraft's control requests cannot be withdrawn, so a call that times out in the page still runs when the app reaches it, as it does for a desktop client. + +```js +const doc = await wordcraft.docInspect(); // a summary of the open document +await wordcraft.execute("text.insert", { text: "Hello" }); +const { data } = await wordcraft.screenshot(); // the canvas as PNG (base64) +``` + +The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above. + ## Renderer selection and fallback flags WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From c4d5f70c1d4903ae759d41406d00a52ad9198728 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 01:55:24 -0300 Subject: [PATCH 29/47] DOCX: resolve LibreOffice font fallback lists to first installed family (#5843) (#5851) * #5843: resolve LibreOffice semicolon-separated font fallback lists to first installed family * #5843: address review comments on font fallback list resolution * #5843: verify fallback list text on cache hit and accept Times New Roman in tests * #5843: bound the fallback lists read from a DOCX and reuse their classification A document could name thousands of fallback families in `w:ascii`/`w:hAnsi`. Nothing bounded that value, and the name-based rules (roman classification, Thai family, named substitutes) then scanned every candidate for each shaped piece, so opening the document took seconds. `w:ascii` and `w:hAnsi` now keep the leading families within MAX_FONT_NAME bytes, cut at a `;` so no name is split, as `w:cs`/`w:eastAsia` already bound their names. The family a real list would pick is kept: a LibreOffice list names a few families, well under the bound. The Thai-family and named-substitute answers are worked out once per list and kept in the fallback-list cache, keyed by the list text, so shaping no longer rescans candidates for every piece. Resolution still reads every candidate. A 500-paragraph document whose font names 20,000 candidates laid out in 10.8 s; bounded on read it takes 17 ms. Tests: - a hostile fallback list keeps its leading families, cut on a `;` - a real LibreOffice list is read whole - a later Thai or named fallback still classifies, and lists are cached --- crates/doc/src/symbol_fonts.rs | 9 +- crates/docx/src/read/parts.rs | 2 + crates/docx/src/read/props.rs | 32 ++- crates/docx/tests/it/fixtures.rs | 36 +++ crates/engine/src/tests/fonts.rs | 40 ++++ crates/fonts/src/word.rs | 398 +++++++++++++++++++++++++++++-- crates/layout/src/para/chars.rs | 10 +- 7 files changed, 500 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/symbol_fonts.rs b/crates/doc/src/symbol_fonts.rs index 775de27736..1d5175540b 100644 --- a/crates/doc/src/symbol_fonts.rs +++ b/crates/doc/src/symbol_fonts.rs @@ -19,8 +19,8 @@ //! //! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. -/// The symbol fonts this module knows, by lowercase family name. -fn table(family: &str) -> Option<&'static [u32; 224]> { +/// The symbol table for a single family name, by lowercase family name. +fn single_table(family: &str) -> Option<&'static [u32; 224]> { let f = family.trim(); if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { Some(&SYMBOL) @@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> { } } +/// The symbol fonts this module knows, by lowercase family name. +fn table(family: &str) -> Option<&'static [u32; 224]> { + if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) } +} + /// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, /// Webdings), whose characters [`symbol_char`] maps. pub fn is_symbol_font(family: &str) -> bool { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index 2e1f98e32f..ce4184dc7d 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -55,6 +55,8 @@ impl Reader<'_> { .children("w:font") .filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman")) .filter_map(|f| f.attr("w:name")) + .flat_map(|n| n.split(';')) + .map(str::trim) .filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME) .take(MAX_TABLE_FONTS) .map(str::to_string) diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index a3d8558bb1..182ee179f2 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -13,6 +13,7 @@ use wordcraft_doc::section::{ PageVAlign, SectionProps, SectionStart, }; +use super::MAX_FONT_NAME; use super::drawing_color::adjust_lum; use crate::units::{int, line_multiple, measure, on_off, tw, u32_of}; @@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: & } } +/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading +/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file +/// names its Latin font as a semicolon-separated fallback list, and a real list names a few +/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay +/// for its whole length as the list is classified and resolved. Keeping the leading families keeps +/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia` +/// drop an over-long name. +fn bounded_font_list(v: &str) -> Option { + if v.len() <= MAX_FONT_NAME { + return (!v.is_empty()).then(|| v.to_string()); + } + let mut end = 0usize; + for part in v.split_inclusive(';') { + let next = end + part.len(); + if next > MAX_FONT_NAME { + break; + } + end = next; + } + let kept = v.get(..end)?.trim_end_matches(';'); + (!kept.is_empty()).then(|| kept.to_string()) +} + impl PropCtx { pub fn style_id(&self, id: &str) -> String { self.alias.get(id).cloned().unwrap_or_else(|| id.to_string()) @@ -226,6 +250,8 @@ impl PropCtx { shd_fill(e) } + /// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size, + /// weight and posture of the complex-script and East Asian text beside them. pub fn rpr(&self, e: &El) -> CharProps { let mut c = CharProps::default(); for k in e.els() { @@ -234,7 +260,11 @@ impl PropCtx { "w:rFonts" => { // A theme font supersedes the fixed font beside it (`w:asciiTheme` over // `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`). - let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string); + // A LibreOffice file names the Latin font as a semicolon-separated fallback + // list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is + // corrupt or hostile, and every piece of text drawn in it would pay for its + // whole length as the list is classified (see `bounded_font_list`). + let named = |a: &str| k.attr(a).and_then(bounded_font_list); let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t)); // `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` / // `font_cs`: `w:hint` only picks between them for ambiguous characters, never for diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 9f82b95308..9d2e902505 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -3947,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() { } } +/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family +/// within the bound, so the family a real list would pick survives while a hostile list cannot +/// cost its whole length in every piece of text drawn in it. +#[test] +fn a_hostile_fallback_list_keeps_its_leading_families() { + let candidates: Vec = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect(); + let list = format!("{};Source Serif 4", candidates.join(";")); + let body = format!(r#"x"#); + let d = read_body(&body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + let kept = c.font.as_deref().expect("the leading families are kept"); + assert!(kept.len() <= 256, "{} bytes kept", kept.len()); + assert!(kept.starts_with("MissingCandidateFont0"), "{kept}"); + // Every kept entry is a whole family name: the cut lands on a `;`, never inside a name. + assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}"); + assert!(!kept.ends_with(';'), "no dangling separator: {kept}"); +} + +/// A real LibreOffice fallback list is read whole: a few families, well under any bound. +#[test] +fn a_real_fallback_list_is_read_whole() { + let body = r#"x"#; + let d = read_body(body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial")); +} + /// The font table's family classes: Word draws a missing roman font in a serif face. #[test] fn roman_fonts_are_read_from_the_font_table() { @@ -3956,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() { assert_eq!(d.settings.roman_fonts, ["Brand Serif"]); } +/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family. +#[test] +fn roman_fonts_split_fallback_lists() { + let fonts = r#""#; + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/fontTable.xml", fonts)])).unwrap(); + assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]); +} + /// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. #[test] fn html_auto_spacing_off_is_read_and_kept() { diff --git a/crates/engine/src/tests/fonts.rs b/crates/engine/src/tests/fonts.rs index 103f2029c9..960c56329f 100644 --- a/crates/engine/src/tests/fonts.rs +++ b/crates/engine/src/tests/fonts.rs @@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { assert_eq!(m[0].shown_as, [shown], "{m:?}"); assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); } + +/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any +/// candidate is installed, and substitutes by candidate family when none is. +#[test] +fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { + let db = wordcraft_fonts::FontDb::global(); + let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(doc.clone()); + assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); + } + let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); + assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); + + // Roundtrip through DOCX export and import: + let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); + let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); + assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(loaded); + assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); + } + + // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: + if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); + } + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!( + drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", + "drawn: {drawn}" + ); +} diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5c73939c22..4860faf5a0 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] { /// as Liberation Serif for Times New Roman, or the same design under another name), rather than /// the generic lists any unknown font gets? pub fn has_named_substitutes(family: &str) -> bool { + if family.contains(';') { + return list_class(family).named; + } substitutes_by_name(charset_base_family(family)).1 } +const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + /// [`substitutes`], and whether they were chosen for this family by name. fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { - const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + if family.contains(';') { + for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let (subs, named) = substitutes_by_name(cand); + if named || is_thai_family(cand) || subs != SANS { + return (subs, named); + } + } + if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) { + return substitutes_by_name(first); + } + } const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"]; const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"]; const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"]; @@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { /// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's /// PDFs give the fonts (`/Ascent`, `/Descent`). pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { - match family.trim().to_ascii_lowercase().as_str() { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() { "symbol" | "symbol (pcl6)" => Some((1.005, 0.216)), "wingdings" => Some((0.899, 0.205)), _ => None, - } + }) } /// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the @@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator + '_ { /// wraps and paginates as in `family`. pub fn metric_compatible(family: &str, substitute: &str) -> bool { let s = substitute.trim(); - metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s)) + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let base = charset_base_family(f); + base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s)) + }) } /// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common @@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool { /// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the /// whole run, never in a Latin substitute (#1549). pub fn is_thai_family(family: &str) -> bool { - let f = family.to_ascii_lowercase(); + if family.contains(';') { + return list_class(family).thai; + } + let f = family.trim().to_ascii_lowercase(); is_thai(&f) || is_thai_serif(&f) } +/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks +/// them for every piece of every run set in the list, so the answer is worked out once per list +/// rather than once per piece. +#[derive(Clone, Copy, Default)] +struct ListClass { + /// [`is_thai_family`]. + thai: bool, + /// [`has_named_substitutes`]. + named: bool, +} + +/// [`ListClass`] of the semicolon-separated `family`, memoised by its text. +fn list_class(family: &str) -> ListClass { + let key = family_list_key(family); + let generation = FontDb::global().generation(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached, class)) = c.classes.get(&key) + && cached == family + { + return *class; + } + } + let class = ListClass { thai: classify_thai(family), named: classify_named(family) }; + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.classes.len() >= FAMILY_CACHE_MAX { + c.classes.clear(); + } + c.classes.insert(key, (family.to_string(), class)); + } + class +} + +/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers. +fn classify_thai(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let f = f.to_ascii_lowercase(); + is_thai(&f) || is_thai_serif(&f) + }) +} + +/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`] +/// answers. +fn classify_named(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1) +} + /// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's /// own and Noto's. Matched by name only. fn is_khmer(f: &str) -> bool { @@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex { } const FAMILY_CACHE_MAX: usize = 4096; -/// Family names longer than this (bytes) are resolved but never remembered. +/// Family names longer than this (bytes) are resolved but never remembered as string keys. const FAMILY_KEY_MAX: usize = 256; +/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list +/// for candidate names not present in [`FontDb::families`]. +const MAX_DIRECT_CANDIDATE_PROBES: usize = 32; + +/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`]. +#[derive(Default)] +struct FallbackListMemo { + generation: u64, + families: std::collections::HashSet, + probed: HashMap, + lists: HashMap<(u64, usize), (String, (String, bool))>, + long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>, + classes: HashMap<(u64, usize), (String, ListClass)>, +} + +/// Shared cache for fallback-list resolutions and candidate availability checks. +fn fallback_list_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default())) +} + +/// Hash and byte length of `family`, used as a compact cache key for fallback lists. +fn family_list_key(family: &str) -> (u64, usize) { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + family.hash(&mut h); + (h.finish(), family.len()) +} + +/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded +/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate. +fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool { + let cand = cand.trim(); + if cand.is_empty() || cand.len() > FAMILY_KEY_MAX { + return false; + } + let generation = db.generation(); + let lower = cand.to_ascii_lowercase(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } + if c.families.is_empty() { + c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect(); + } + if c.families.contains(&lower) { + return true; + } + if let Some(&hit) = c.probed.get(&lower) { + return hit; + } + } + if *direct_probes == 0 { + return false; + } + *direct_probes -= 1; + let hit = db.has_family(cand); + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.probed.len() >= FAMILY_CACHE_MAX { + c.probed.clear(); + } + c.probed.insert(lower, hit); + } + hit +} + +/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list. +fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.lists.len() >= FAMILY_CACHE_MAX { + c.lists.clear(); + } + c.lists.insert(key, (family.to_string(), result.clone())); + } + result +} + +/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`. +fn resolve_family(db: &FontDb, family: &str) -> (String, bool) { + if !family.contains(';') { + let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; + return if db.has_family(family) { + (family.to_string(), false) + } else if base != family && db.has_family(base) { + (base.to_string(), false) + } else { + (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) + }; + } + + let generation = db.generation(); + let list_key = family_list_key(family); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.lists.get(&list_key) + && cached_family == family + { + return r.clone(); + } + } + + let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect(); + if candidates.is_empty() { + return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)); + } + + let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES; + + // 1. First installed family in the list: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if candidate_has_family(db, cand, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (cand.to_string(), false)); + } + if base != cand && candidate_has_family(db, base, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (base.to_string(), false)); + } + } + + // 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito. + // Try Thai substitutes before metric twins of later candidates: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if is_thai_family(base) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES; + if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) { + return remember_list_resolution(generation, list_key, family, (twin.to_string(), true)); + } + } + + // Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono): + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + let (subs, named) = substitutes_by_name(base); + if (named || is_thai_family(base) || subs != SANS) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + // Fall back to the first candidate's substitute: + if let Some(&first) = candidates.first() { + let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) }; + if let Some(sub) = substitute(db, base) { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)) +} /// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, /// [`FontDb::generation`]). @@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { let generation = db.generation(); let cacheable = family.len() <= FAMILY_KEY_MAX; let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic); + let long_key = (!cacheable && family.contains(';')).then(|| { + let (h, len) = family_list_key(family); + (h, len, bold, italic) + }); { let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if c.0 != generation { @@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { return *r; } } - // Printer-driver Symbol aliases use the same encoding as Symbol. - let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; - let (fam, substituted) = if db.has_family(family) { - (family.to_string(), false) - } else if base != family && db.has_family(base) { - // Windows maps the charset names to the font itself: not a substitution. - (base.to_string(), false) - } else { - (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) - }; + if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.long_resolved.get(&lk) + && cached_family == family + { + return *r; + } + } + let (fam, substituted) = resolve_family(db, family); let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; - let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if cacheable && c.0 == generation { - if c.1.len() >= FAMILY_CACHE_MAX { - c.1.clear(); + if cacheable { + let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.0 == generation { + if c.1.len() >= FAMILY_CACHE_MAX { + c.1.clear(); + } + c.1.insert(key, r); + } + } else if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.long_resolved.len() >= FAMILY_CACHE_MAX { + c.long_resolved.clear(); + } + c.long_resolved.insert(lk, (family.to_string(), r)); } - c.1.insert(key, r); } r } @@ -787,4 +1033,114 @@ mod tests { assert!(!has_named_substitutes(f), "{f}"); } } + + /// Semicolon-separated fallback lists resolve to the first installed family, or to a + /// candidate's substitute when no listed family is installed. + #[test] + fn libreoffice_fallback_list_resolves_first_installed_family() { + let db = FontDb::global(); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let r = resolve("Liberation Sans;Arial", false, false); + assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family); + if db.has_family("Liberation Sans") { + assert_eq!(r.face.family, "Liberation Sans"); + } else { + assert_eq!(r.face.family, "Arial"); + } + } + let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false); + assert!(!bundled.substituted); + assert_eq!(bundled.face.family, "Source Serif 4"); + + // When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito: + if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") { + let r = resolve("NonExistentSansFace123;Times New Roman", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman"); + assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family); + } + let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family"); + assert!( + r.face.family.contains("Serif") + || r.face.family == "Times" + || r.face.family == "Times New Roman" + || r.face.family == "Tinos" + || r.face.family == "Caladea" + || r.face.family == "Georgia", + "got {:?}", + r.face.family + ); + + // Metric compatibility of fallback list: + assert!(metric_compatible("Open Sans;Arial", "Liberation Sans")); + assert!(metric_compatible("Liberation Sans;Arial", "Helvetica")); + assert!(!metric_compatible("Open Sans;Arial", "Carlito")); + } + + /// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an + /// earlier Thai substitute takes precedence over a later candidate's metric twin. + #[test] + fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() { + let db = FontDb::global(); + let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else { + eprintln!("skipped: no Thai substitute installed"); + return; + }; + if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) { + let r = resolve("Cordia New;Arial", false, false); + assert!(r.substituted); + assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family); + } + if let Some(latin) = + METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t))) + { + let list = format!("NonExistentThaiUPC;{latin}"); + let r = resolve(&list, false, false); + assert!(r.substituted); + assert_eq!(r.face.family, thai_sub); + } + } + + /// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and + /// cache their resolution without growing `resolve_cache` string keys. + #[test] + fn long_fallback_list_selects_later_installed_family_and_caches() { + let mut candidates: Vec = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("Source Serif 4".to_string()); + let list = candidates.join(";"); + assert!(list.len() > FAMILY_KEY_MAX); + + let r1 = resolve(&list, false, false); + let r2 = resolve(&list, false, false); + assert!(!r1.substituted); + assert_eq!(r1.face.family, "Source Serif 4"); + assert_eq!(r1.face.id(), r2.face.id()); + assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX)); + } + + /// The name-based predicates look at every candidate, and their answer is cached per list: a + /// later Thai or named fallback still counts, and a list is classified once rather than for + /// every piece of text shaped in it. + #[test] + fn list_classification_reads_every_candidate_and_is_cached() { + // The Thai family is last, past any shorter bound, and still classifies the list. + let mut candidates: Vec = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("TH Sarabun New".to_string()); + let thai_list = candidates.join(";"); + assert!(is_thai_family(&thai_list), "a later Thai family still counts"); + + // A named substitute at the end classifies too (Times New Roman has Liberation Serif). + candidates.pop(); + candidates.push("Times New Roman".to_string()); + let named_list = candidates.join(";"); + assert!(has_named_substitutes(&named_list), "a later named fallback still counts"); + + // Both are memoised by list text: a second ask answers without rescanning, and a list + // whose text differs only after the key's hash is not mistaken for the cached one. + assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list)); + let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len(); + assert!(cached >= 2, "{cached} lists classified"); + } } diff --git a/crates/layout/src/para/chars.rs b/crates/layout/src/para/chars.rs index 28de11515e..b68d578287 100644 --- a/crates/layout/src/para/chars.rs +++ b/crates/layout/src/para/chars.rs @@ -130,13 +130,17 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria"; /// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing. pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str { let db = wordcraft_fonts::FontDb::global(); - let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font)); + let roman = !doc.settings.roman_fonts.is_empty() + && font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f))); if roman && !wordcraft_fonts::word::is_thai_family(font) && !wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::has_named_substitutes(font) - && !db.has_family(font) - && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + && if font.contains(';') { + wordcraft_fonts::word::resolve(font, false, false).substituted + } else { + !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + } { MISSING_ROMAN_FONT } else { From cd4f755dcf970fd5ea4b33567fabcac4da53cad9 Mon Sep 17 00:00:00 2001 From: tpawley2001 Date: Sat, 7 Nov 2026 20:59:51 -0800 Subject: [PATCH 30/47] DOCX: nested block-level content controls stay block-level when a bookmark follows them, so Word opens the file (#5811) ## What changed The DOCX writer's content-control planner (`crates/docx/src/write/sdt.rs`) decides whether a control that covers a whole paragraph is written block-level (`w:sdt` around `w:p`) or run-level (inside the paragraph). It only treated the control as covering the whole paragraph when nothing but control-end markers followed its end. Bookmark, comment and permission range markers after the end now count as "nothing" too, so the control stays block-level and the markers stay at the end of its paragraph. ## Why Word writes a body-level `_GoBack` bookmark (`` directly in `w:body`) in nearly every file it saves. When the last body-level content control was followed by it, the reader put the bookmark at the end of the control's paragraph, and the writer then turned the control (and any controls nested in it) into run-level `w:sdt`s inside that paragraph. For a repeating section (`w15:repeatingSection` / `w15:repeatingSectionItem`), which must wrap whole paragraphs or rows, Microsoft 365 Word then refuses to open the saved file ("the file appears to be corrupted"). Plain nested block controls changed what they cover the same way. Found by the real-file corpus: Word reported our saved copy of a form with a repeating section as corrupted. ## How it was tested - New tests in `crates/docx/tests/it/nested_block_controls.rs` (synthetic packages): a repeating section around one paragraph, plain nested block controls, nested block controls in a table cell, a run-level control that must stay in its paragraph, and repeating sections / block controls followed by a body-level bookmark (with and without data bindings) all round-trip with the controls at the same level and the text kept. - `cargo xtask ci` passes. Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/docx/src/write/sdt.rs | 19 +- crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/nested_block_controls.rs | 180 ++++++++++++++++++ docs/file-format-parity.md | 1 + 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 crates/docx/tests/it/nested_block_controls.rs diff --git a/crates/docx/src/write/sdt.rs b/crates/docx/src/write/sdt.rs index 0f8f5a4a92..1b0f4d401b 100644 --- a/crates/docx/src/write/sdt.rs +++ b/crates/docx/src/write/sdt.rs @@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> { && (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. }))) }) }; + // Bookmark, comment and permission markers after the end may stay there: a body-level + // bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph, + // and the control still covers the whole paragraph. let at_end = |b: usize, off: usize| { + use wordcraft_doc::InlineObject as O; para(b).is_some_and(|p| { let from = off + OBJ.len_utf8(); p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ)) - && (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd))) + && (from..p.len()).step_by(OBJ.len_utf8()).all(|o| { + matches!( + p.object_at(o), + Some( + O::ControlEnd + | O::BookmarkStart { .. } + | O::BookmarkEnd { .. } + | O::CommentStart { .. } + | O::CommentEnd { .. } + | O::PermStart { .. } + | O::PermEnd { .. } + ) + ) + }) }) }; let mut mode = vec![Mode::Inline; ranges.len()]; diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 8a1addee7d..68caa1f5be 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -21,6 +21,7 @@ mod heading_levels; mod legacy_forms; mod ligatures; mod malformed; +mod nested_block_controls; mod note_comments; mod passthrough; mod picture_links; diff --git a/crates/docx/tests/it/nested_block_controls.rs b/crates/docx/tests/it/nested_block_controls.rs new file mode 100644 index 0000000000..30a0c272b2 --- /dev/null +++ b/crates/docx/tests/it/nested_block_controls.rs @@ -0,0 +1,180 @@ +//! A body-level content control nested directly in another body-level one stays body-level when +//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15` +//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic. + +use std::io::Write; + +use wordcraft_doc::{ContentControl, Document, StoryRef}; +use wordcraft_docx::{read, write}; + +const NS: &str = + r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#; +const ROOT_RELS: &str = r#""#; + +fn package(body: &str) -> Vec { + let doc = format!(r#"{body}"#); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(docx: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap(); + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap(); + s +} + +fn t(s: &str) -> String { + format!(r#"{s}"#) +} +fn p(inner: &str) -> String { + format!("{inner}") +} +fn sdt(pr: &str, content: &str) -> String { + format!("{pr}{content}") +} + +fn controls(d: &Document) -> Vec { + let mut out = Vec::new(); + wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone())); + out +} + +/// How many `w:p` elements are open at byte `at` of `xml`. +fn open_paragraphs(xml: &str, at: usize) -> usize { + let head = &xml[..at]; + let opened = head.matches("").count() + head.matches("").count() +} + +/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`. +fn depth_of_control(xml: &str, marker: &str) -> usize { + let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}")); + let start = xml[..at].rfind("").expect("control start"); + open_paragraphs(xml, start) +} + +fn round_trip(body: &str) -> (Document, Document, String) { + let d = read(&package(body)).unwrap(); + let bytes = write(&d).unwrap(); + let back = read(&bytes).unwrap(); + (d, back, document_xml(&bytes)) +} + +fn repeating_section() -> String { + let field = sdt(r#""#, &t("note 1")); + let item = sdt(r#""#, &p(&format!("{field}{}", t(" ")))); + let section = sdt(r#""#, &item); + format!("{}{section}{}", p(&t("Notes")), p(&t("End"))) +} + +#[test] +fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() { + let (_, _, xml) = round_trip(&repeating_section()); + assert_eq!(depth_of_control(&xml, r#""#), 0, "section written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "item written inside a paragraph:\n{xml}"); + // The text control stays inside the paragraph, and the item holds that paragraph. + assert_eq!(depth_of_control(&xml, r#""#), 1); + assert!(xml.contains(""#, &p(&t("Only paragraph"))); + let outer = sdt(r#""#, &inner); + let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After")))); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(controls(&back), controls(&d)); +} + +#[test] +fn nested_block_controls_in_a_table_cell_stay_block_level() { + let inner = sdt(r#""#, &p(&t("In cell"))); + let outer = sdt(r#""#, &inner); + let table = format!(r#"{outer}"#); + let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After")))); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); +} + +#[test] +fn a_run_level_control_stays_inside_its_paragraph() { + let field = sdt(r#""#, &t("value")); + let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: ")))); + assert_eq!(depth_of_control(&xml, r#""#), 1, "{xml}"); +} + +/// A bound repeating section as Word writes one at the end of a form, optionally followed by +/// the body-level `_GoBack` bookmark Word leaves after the last control. +fn bound_section_body(bound: bool, bookmark: bool) -> String { + let section_binding = if bound { + r#""# + } else { + "" + }; + let trailing = if bookmark { r#""# } else { "" }; + let bind = |x: &str| format!(r#""#); + let field = format!( + r#"{}{}"#, + if bound { bind("/a[1]/n[1]") } else { String::new() }, + t("note 1") + ); + let item = format!( + r#"{}"#, + p(&format!("{field}{}", t(" "))) + ); + let section = format!( + r#"{section_binding}{item}"# + ); + format!(r#"{}{section}{trailing}"#, p(&t("Notes"))) +} + +fn assert_section_block_level(body: &str) { + let (d, back, xml) = round_trip(body); + assert_eq!(depth_of_control(&xml, r#""#), 0, "section written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "item written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 1, "{xml}"); + assert_eq!(controls(&back), controls(&d)); + assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body)); +} + +#[test] +fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() { + assert_section_block_level(&bound_section_body(true, true)); +} + +#[test] +fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() { + assert_section_block_level(&bound_section_body(true, false)); +} + +#[test] +fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() { + assert_section_block_level(&bound_section_body(false, true)); +} + +#[test] +fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() { + let inner = sdt(r#""#, &p(&t("Last paragraph"))); + let outer = sdt(r#""#, &inner); + let body = format!(r#"{}{outer}"#, p(&t("First"))); + let (d, back, xml) = round_trip(&body); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(controls(&back), controls(&d)); + // The bookmark is kept. + assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b8296f0511..f0022bfe8b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -682,3 +682,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-03 | trivial | Opening a password-protected file named on the command line shows the Password box also when a chosen region replaced the first blank document (the window followed the replacement only on the first frame, which dropped the box, #5299) | | 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. | | 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. | +| 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). | From 7634553900e6f3cd7f42d587fa3ed57d82f12832 Mon Sep 17 00:00:00 2001 From: Kevin Joseph <47004376+kevinj24fr@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:02:01 +0100 Subject: [PATCH 31/47] Layout: superscript and subscript text at 0.65 of its size rounded to the half point (#5802) Word for Mac sets raised and lowered text at 0.65 of the run's size rounded to the half point (measured from digit advances in its PDF export at base sizes 8 to 36 pt: 11 pt -> 7, 12 -> 8, 24 -> 15.5). WordCraft used 0.65 exactly, so lines with superscripts were up to 0.5 pt wider and some broke a word early. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/doc/src/resolve.rs | 33 +++++++++++++++++++++++++++++++-- crates/layout/src/tests.rs | 28 ++++++++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ce5a54e177..5f557c6f9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) - 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. +- 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged. - 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. @@ -877,6 +878,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | +| 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) | | 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/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 8c35c6862f..40449e82cb 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -137,9 +137,12 @@ impl ResolvedChar { pub fn uses_complex(&self, c: char) -> bool { self.rtl || self.cs || crate::bidi::is_complex_script(c) } - /// The size glyphs are drawn at (super/subscript shrink to ~2/3). + /// The size glyphs are drawn and measured at. Word sets superscript and subscript text at + /// 0.65 of the run's size rounded to the half point, a font size the format can store + /// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8, + /// 16 → 10.5, 24 → 15.5, 36 → 23.5). pub fn draw_size(&self) -> f32 { - if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 } + if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 } } /// Baseline shift, points (positive = up). pub fn baseline_shift(&self) -> f32 { @@ -453,6 +456,32 @@ fn diff_patch(with: &T, wi mod tests { use super::*; + /// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in + /// its PDF export): 0.65 of the size, rounded to the half point. + #[test] + fn raised_and_lowered_text_is_sized_to_the_half_point() { + for (size, want) in [ + (8.0, 5.0), + (9.0, 6.0), + (10.0, 6.5), + (11.0, 7.0), + (12.0, 8.0), + (13.0, 8.5), + (14.0, 9.0), + (16.0, 10.5), + (20.0, 13.0), + (24.0, 15.5), + (36.0, 23.5), + ] { + for vert_align in [VertAlign::Superscript, VertAlign::Subscript] { + let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) }; + assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}"); + } + } + let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() }); + assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size"); + } + /// #2036: only a character itself (direct formatting) has a symbol font of its own; a /// style never gives one to its text. #[test] diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc5df29f05..891e417982 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8980,6 +8980,34 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() { assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}"); } +#[test] +fn superscript_digits_advance_at_the_half_point_size() { + use wordcraft_doc::props::VertAlign; + // Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its + // digits advance by 7pt digit widths (Word for Mac, measured from its PDF export). + let width = |size: f32, vert_align: VertAlign| { + let mut p = wordcraft_doc::Paragraph::new(); + let props = + wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() }; + p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() }); + p.text.push_str("1234567890"); + p.mark = props; + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = lay(&d); + // From the first digit's start to the last's: nine digit advances. + let xs = l.pages[0] + .items + .iter() + .find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None }) + .unwrap(); + xs.last().unwrap() - xs.first().unwrap() + }; + let seven = width(7.0, VertAlign::Baseline); + assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript)); + assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01); +} + #[test] fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() { use wordcraft_doc::para::RubyAlign; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 942c87e8af..d395d6c126 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -45,7 +45,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 | | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | -| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | +| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | @@ -309,6 +309,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 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-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` | | 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 85434d2c20adbf3a270d9e320167c08251eec8ec Mon Sep 17 00:00:00 2001 From: Kevin Joseph <47004376+kevinj24fr@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:26:44 +0100 Subject: [PATCH 32/47] layout: line numbers take the "Line Number" style, not a fixed 10 pt grey (#5788) * layout: line numbers take the "Line Number" style, not a fixed 10 pt grey Line numbers were laid out in 10 pt grey in the default font, whatever the document said. Word formats them with the "Line Number" character style over the document's defaults: in a manuscript whose defaults are Aptos 12 pt, Word's PDF shows them in Aptos 12 pt black (measured in Word for Mac), WordCraft's in 10 pt grey. Lay the number out with the document's "Line Number" style when it has one, else its plain defaults, so size, font and colour follow the document. Test: line numbers take the document's default size and automatic colour, and a "Line Number" style's size and colour when there is one. * docs: progress log and parity entries for line-number-style --------- Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/ctx.rs | 14 +++++++++----- crates/layout/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 1 + 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5f557c6f9c..74ca29973a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,6 +167,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) - 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. - 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged. +- 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged. - 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. @@ -879,6 +880,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | | 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) | +| 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style | | 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/ctx.rs b/crates/layout/src/ctx.rs index 6be9ebaeeb..4e3d1b4f66 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -4,9 +4,12 @@ use super::headers::HfLinks; use super::placed::Placed; use super::{ Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level, - ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox, + ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox, }; +/// The character style Word formats line numbers with (`w:styleId="LineNumber"`). +const LINE_NUMBER_STYLE: &str = "LineNumber"; + /// A layout pass: finished, or to be done again with the page totals it found, or with the /// floats placed above their anchors it found ([`EarlyFloats`]). pub(crate) enum Pass { @@ -193,10 +196,11 @@ impl Ctx<'_> { if let Some(p) = self.numbers.get(&n) { return p.clone(); } - let mut p = Paragraph::with_text( - &n.to_string(), - CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() }, - ); + // Word formats line numbers with the "Line Number" character style over the document's + // defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are + // Aptos 12 pt), not in a fixed small grey. + let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone()); + let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() }); p.props.space_after = Some(0.0); p.props.space_before = Some(0.0); let env = para::ParaEnv { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 891e417982..2fbf050689 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3851,6 +3851,40 @@ fn extreme_page_and_line_number_starts_do_not_overflow() { assert!(l.pages.len() >= 3, "{} pages", l.pages.len()); } +/// Line numbers take the document's character formatting through the "Line Number" style, as in +/// Word (Aptos 12 pt black in a document whose defaults are Aptos 12 pt), not a fixed small grey. +#[test] +fn line_numbers_follow_the_line_number_style() { + let number_runs = |d: &Document| { + let l = lay(d); + l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)), + _ => None, + }) + .collect::>() + }; + let mut d = Document::from_text("one\ntwo\nthree"); + d.last_section.line_numbers = Some(Default::default()); + Arc::make_mut(&mut d.styles).default_chr.size = Some(12.0); + let plain = number_runs(&d); + assert_eq!(plain.len(), 3); + assert!(plain.iter().all(|(size, color)| *size == 12.0 && *color == wordcraft_doc::TextColor::Auto), "{plain:?}"); + + let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0)); + Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style { + id: "LineNumber".into(), + name: "line number".into(), + kind: wordcraft_doc::StyleKind::Character, + chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() }, + ..Default::default() + }); + let styled = number_runs(&d); + assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}"); +} + #[test] fn line_numbers_borders_text_boxes() { let mut d = Document::from_text("one\ntwo\nthree"); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d395d6c126..1b16088a2d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -310,6 +310,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 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-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` | +| 2026-11-06 | trivial | Line numbers are laid out with the document's "Line Number" character style over its defaults, as Word draws them, not in a fixed 10 pt grey (`layout::tests::line_numbers_follow_the_line_number_style`) | | 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 a891d861b7b881cb0681d52d4ec57c32eeea2e09 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 06:27:07 +0100 Subject: [PATCH 33/47] Layout: contextual spacing drops the space after a cell's last Normal paragraph (#5450) Word treats the end-of-cell mark as a paragraph in the default (Normal) style, so a cell's last paragraph with contextual spacing in that style (or in an undefined style, formatted as Normal) loses its space after, as beside any same-style neighbour; one in another style keeps it. `layout_box` kept the space after of every last cell paragraph except automatic spacing, so the row grew by it and the text below the table sat that much lower (docxide-pdf cell-end-text-context, cell-end-empty-context: 8 pt). A middle or bottom cell of a row split across pages lines up with the other cells' kept text, which `align_in_part` counted with its full space after; it now stops at the cell's content end, so a dropped space after (contextual or automatic) no longer pushes the aligned cell down. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/layout/src/boxes.rs | 8 +- crates/layout/src/table.rs | 6 +- crates/layout/src/tests.rs | 158 +++++++++++++++++++++++++++++++++++++ docs/gaps.md | 3 + docs/layout-parity.md | 2 + 6 files changed, 175 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 74ca29973a..c4166de74b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. - 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged. +- 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged. - 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. @@ -869,6 +870,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| | 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | +| 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) | | 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. | diff --git a/crates/layout/src/boxes.rs b/crates/layout/src/boxes.rs index 0229beebe3..45387a9de2 100644 --- a/crates/layout/src/boxes.rs +++ b/crates/layout/src/boxes.rs @@ -366,8 +366,12 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout { items.splice(at..at, back); items.extend(front); } - // Automatic space after: none after the last paragraph of a cell or box. - let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after }; + // Automatic space after: none after the last paragraph of a cell or box. Nor + // contextual spacing after a cell's last paragraph in the default style (or an + // undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph. + let last = i + 1 == blocks.len(); + let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none(); + let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after }; y += edges.top + pl.height + edges.bottom + after; prev_after = after; prev_after_auto = pl.rp.after_auto; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 4876e940c8..521886bdc3 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1201,14 +1201,16 @@ fn align_in_part( } let limit = cut - row.bottom.clamp(0.0, cut); let mut kept = f32::MIN; - for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) { + for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) { + // No further than the cell's content: its last paragraph may have dropped its space after. + let end = end.unwrap_or(f32::MAX); for (it, &split) in items.iter().zip(splits) { let bottom = match (it.turned_bounds(), it) { (Some(r), _) => Some(r.bottom()), // A paragraph kept whole ends with its space after. (None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split) .filter(|&b| b <= limit + 0.01) - .map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }), + .map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }), (None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => { Some(rect.bottom()) } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2fbf050689..fa7e4a111a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -298,6 +298,82 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() { } } +/// Baseline of the paragraph after a one-row table, less that of the first cell's last +/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell +/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is +/// the last paragraph's text. +fn cell_end_gap(style: &str, contextual: bool) -> f32 { + cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe") +} + +fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 { + use wordcraft_doc::props::LineSpacing; + Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style { + id: "Other".into(), + name: "Other".into(), + based_on: Some("Normal".into()), + ..Default::default() + }); + let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() }; + let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() }; + let para = |text: &str, props: ParaProps| { + let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone()); + p.props = props; + wordcraft_doc::para_block(p) + }; + let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() }; + let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0); + table.props.style = None; + table.props.cell_margins = Some([0.0; 4]); + let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() }; + table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)]; + if let Some(cell) = table.rows[0].cells.get_mut(1) { + cell.blocks = vec![para("Delta probe", styled(next_cell))]; + } + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))]; + let l = lay(&d); + let baseline = |at: &[u32]| { + l.pages[0].items.iter().find_map(|i| match i { + Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline), + _ => None, + }) + }; + let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") }; + after - cell +} + +#[test] +fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() { + // The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a + // Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not + // after one in another style (`cell-end-other-style`) or without contextual spacing. + let kept = cell_end_gap("Normal", false); + assert!((cell_end_gap("Other", true) - kept).abs() < 0.01); + let dropped = cell_end_gap("Normal", true); + assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}"); + // An undefined style is formatted as Normal, and drops it too. + assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01); + // The mark decides, not a neighbour: another style in the next cell or after the table + // leaves the drop as it is. + for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] { + let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe"); + assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}"); + } + // And the reverse: another style keeps its space beside Normal neighbours. + let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe"); + assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}"); + // An empty last paragraph drops it too (`cell-end-empty-context`). + let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, ""); + assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}"); + // Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the + // 12 pt stays. + let mut legacy = Document::new(); + legacy.settings.compat_mode = 14; + legacy.settings.allow_space_of_same_style_in_table = true; + let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe"); + assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}"); +} + fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 { use wordcraft_doc::props::LineSpacing; let mut d = Document::new(); @@ -12186,6 +12262,88 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() { assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}"); } +/// The line tops on the first page of a row split across pages, by cell: a label aligned with +/// `valign`, 30 lines that split, and a 160 pt spacer above `note`. +fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec; 3] { + use wordcraft_doc::props::LineSpacing; + let plain = split_note_plain(); + let para = |text: &str, props: ParaProps| { + let mut p = Paragraph::with_text(text, Default::default()); + p.props = props; + wordcraft_doc::para_block(p) + }; + let mut d = Document::new(); + (d.last_section.page_w, d.last_section.page_h) = (400.0, 360.0); + d.last_section.margin_top = 36.0; + d.last_section.margin_bottom = 36.0; + let mut t = Table::new(1, 3, 328.0); + t.props.style = None; + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].cells[0].props.valign = Some(valign); + t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())]; + t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect(); + let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() }; + t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())]; + d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))]; + let l = lay(&d); + let tops = |cell: u32| -> Vec { + l.pages[0] + .items + .iter() + .filter_map(|item| match item { + Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y), + _ => None, + }) + .collect() + }; + let cells = [tops(0), tops(1), tops(2)]; + assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}"); + assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}"); + cells +} + +/// Single 12 pt lines with no space around them. +fn split_note_plain() -> ParaProps { + ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)), + widow_control: Some(false), + ..Default::default() + } +} + +#[test] +fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() { + use wordcraft_doc::props::VAlign; + // The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would + // otherwise reach below the split lines: a bottom label lines up with those, a middle one is + // centred between their top and bottom. + let plain = split_note_plain(); + let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() }; + let automatic = ParaProps { space_after_auto: Some(true), ..plain }; + for note in [contextual, automatic] { + let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, ¬e); + assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}"); + let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, ¬e); + let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") }; + let want = first + (last - first) / 2.0; + assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}"); + } +} + +#[test] +fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() { + use wordcraft_doc::props::VAlign; + // A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom + // label's line ends 14 pt below the note's. + let note = ParaProps { space_after: Some(14.0), ..split_note_plain() }; + let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, ¬e); + let (Some(&label), Some(¬e_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") }; + assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}"); + assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}"); +} + fn row_keep_pages(d: &Document) -> Vec { let l = lay(d); (0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len()) diff --git a/docs/gaps.md b/docs/gaps.md index 55f8757d42..b5264cd4f1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -88,6 +88,8 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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). | 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 nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) 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). | 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 hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) 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). | 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; a cell's last Normal paragraph drops its contextual space after (#5450); [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; a cell's last Normal paragraph drops its contextual space after (#5450); [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) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -195,6 +197,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| | 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open | +| 2026-11-08 | trivial | #4 narrowed: a cell's last Normal paragraph drops its contextual space after, the end-of-cell mark counting as a Normal paragraph (#5450) | | 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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1b16088a2d..657952ffb4 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -46,6 +46,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | +| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; a cell's last paragraph with contextual spacing, in the Normal style (or an undefined one), drops its space after, the end-of-cell mark counting as a Normal paragraph beside it (not where `allowSpaceOfSameStyleInTable` keeps same-style space in modes 11/12/14); this rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures, and a Word comparison that tells it apart from the neighbouring paragraph's style is still open; a middle or bottom cell of a split row lines up without that dropped space (`contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`, `a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it`); list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | @@ -305,6 +306,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | Date | Change | Summary | |---|---|---| | 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word | +| 2026-11-08 | trivial | Contextual spacing drops the space after a table cell's last paragraph in the Normal style, the end-of-cell mark counting as a Normal paragraph; another style keeps it, and a middle or bottom cell of a split row lines up without the dropped space (#5450) (`layout::tests::contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`). | | 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`). | From c8618d3e05d0972e1321ca87b1c018078fc65fea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 00:34:27 -0500 Subject: [PATCH 34/47] fix(docx): look relationships up by id through an index (#5410) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/docx/src/package.rs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index c4166de74b..7b8060a4bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -250,6 +250,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 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. - 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) - 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288) +- 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 3835bdc0b6..656a2ad274 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -350,6 +350,7 @@ impl Package { let path = resolve(dir, target); self.canonical(&path).map_or(path, str::to_string) }; + rels.ids.entry(id.to_string()).or_insert(rels.list.len()); rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external }); } rels @@ -405,11 +406,13 @@ pub struct Rel { #[derive(Clone, Debug, Default)] pub struct Rels { pub list: Vec, + /// Each id's first relationship in `list`, so a lookup doesn't scan every relationship. + ids: HashMap, } impl Rels { pub fn by_id(&self, id: &str) -> Option<&Rel> { - self.list.iter().find(|r| r.id == id) + self.ids.get(id).and_then(|&i| self.list.get(i)) } pub fn by_type(&self, full: &str) -> Option<&Rel> { self.list.iter().find(|r| rel_is(&r.kind, full)) @@ -547,6 +550,22 @@ mod tests { assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan")); } + #[test] + fn relationships_are_looked_up_by_id_the_first_one_winning() { + let mut rels = String::from(r#""#); + for i in 0..20_000 { + rels.push_str(&format!(r#""#)); + } + rels.push_str(r#""#); + let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]); + let r = Package::open(&z).unwrap().rels("word/document.xml"); + assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins"); + assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999")); + assert!(r.by_id("rId20000").is_none()); + // Hyperlink-style lookups, one per relationship, are each a map lookup. + assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000); + } + #[test] fn decompression_bomb_is_rejected() { let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]); From 535b675e8a02993c34e73bf5bc58c44e637ec99c Mon Sep 17 00:00:00 2001 From: tpawley2001 Date: Sat, 7 Nov 2026 21:39:22 -0800 Subject: [PATCH 35/47] DOCX: paragraph indents and spacing beyond 16 bits wrap as in Word (#5655) ## What changed Word stores paragraph indents (`w:ind` left/start, right/end, hanging, firstLine) in 16 signed bits and paragraph spacing (`w:spacing` before/after) in 16 unsigned bits. A file that writes a larger plain integer is read by Word modulo 2^16: `w:left="66256"` is a 720-twip (0.5") indent in Word. We read the full value, so such a paragraph was indented by about 46 inches and its text was pushed off the page. - `crates/docx/src/units.rs`: new `tw_i16` / `tw_u16` readers. A plain integer keeps its low 16 bits (two's complement for indents, so 66256 -> 720 and 65535 -> -1); values with a unit or a fraction read as before. - `crates/docx/src/read/props.rs` and `read/styles.rs` use them for `w:ind` and `w:spacing` before/after, in direct and style formatting. - In-range values read exactly as before. ## Testing - `crates/docx/tests/it/twips_wrap.rs`: synthetic documents with out-of-range indents and spacing in paragraph and style properties check the wrapped values; in-range values and values with units are unchanged. - Unit tests for the two readers; `cargo xtask ci` passes. Found by a real-file corpus comparison against Word's own PDFs: a file whose indents exceed 16 bits shows its text on the page in Word and off the page here. Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- crates/docx/src/read/props.rs | 14 ++++---- crates/docx/src/read/styles.rs | 7 ++-- crates/docx/src/units.rs | 39 +++++++++++++++++++++ crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/twips_wrap.rs | 55 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 6 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 crates/docx/tests/it/twips_wrap.rs diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 182ee179f2..2a218b2c47 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -15,7 +15,7 @@ use wordcraft_doc::section::{ use super::MAX_FONT_NAME; use super::drawing_color::adjust_lum; -use crate::units::{int, line_multiple, measure, on_off, tw, u32_of}; +use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of}; /// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in /// points (clamped), names kept as OOXML writes them. @@ -407,10 +407,10 @@ impl PropCtx { p.tabs = Some(tabs); } "w:spacing" => { - if let Some(v) = tw(k, "w:before") { + if let Some(v) = tw_u16(k, "w:before") { p.space_before = Some(v.max(0.0)); } - if let Some(v) = tw(k, "w:after") { + if let Some(v) = tw_u16(k, "w:after") { p.space_after = Some(v.max(0.0)); } // HTML-style automatic spacing; "0" switches an inherited one off. @@ -433,15 +433,15 @@ impl PropCtx { } } "w:ind" => { - if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) { + if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) { p.indent_left = Some(v); } - if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) { + if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) { p.indent_right = Some(v); } - if let Some(v) = tw(k, "w:hanging") { + if let Some(v) = tw_i16(k, "w:hanging") { p.indent_first = Some(-v); - } else if let Some(v) = tw(k, "w:firstLine") { + } else if let Some(v) = tw_i16(k, "w:firstLine") { p.indent_first = Some(v); } // Character units (hundredths of a character), which win when non-zero. diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 878edddf98..6e972a7604 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -2,6 +2,7 @@ use super::{ AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts, VAlign, flag, int, props, tw, u32_of, }; +use crate::units::tw_i16; use std::sync::Arc; /// Built-in style names Word stores in lower case, and how the UI shows them. @@ -317,12 +318,12 @@ impl Reader<'_> { lv.align = props::align(j); } if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) { - if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) { + if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) { lv.indent = v; } - if let Some(h) = tw(ind, "w:hanging") { + if let Some(h) = tw_i16(ind, "w:hanging") { lv.hanging = h; - } else if let Some(f) = tw(ind, "w:firstLine") { + } else if let Some(f) = tw_i16(ind, "w:firstLine") { lv.hanging = -f; } } diff --git a/crates/docx/src/units.rs b/crates/docx/src/units.rs index c9862fb047..2e5dfb0cd5 100644 --- a/crates/docx/src/units.rs +++ b/crates/docx/src/units.rs @@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option { e.attr(attr).and_then(|v| measure(v, 20.0)) } +/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12): +/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it +/// (66256 → 720). Values with a unit or a fraction read as [`tw`]. +pub fn tw_i16(e: &El, attr: &str) -> Option { + let v = e.attr(attr)?; + match v.trim().parse::() { + Ok(i) => Some(f32::from(i as i16) / 20.0), + Err(_) => measure(v, 20.0), + } +} + +/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above +/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`]. +pub fn tw_u16(e: &El, attr: &str) -> Option { + let v = e.attr(attr)?; + match v.trim().parse::() { + Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0), + _ => measure(v, 20.0), + } +} + /// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off. pub fn on_off(e: &El) -> bool { match e.attr("w:val") { @@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String { mod tests { use super::*; + #[test] + fn twips_beyond_16_bits_wrap() { + let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() }; + let s = |a: &str| tw_i16(&el(a), "w:v"); + let u = |a: &str| tw_u16(&el(a), "w:v"); + assert_eq!(s("66256"), Some(36.0)); + assert_eq!(s("-65516"), Some(1.0)); + assert_eq!(s("98304"), Some(-1638.4)); + assert_eq!(s("32767"), Some(1638.35)); + assert_eq!(s("-32768"), Some(-1638.4)); + assert_eq!(s("1in"), Some(72.0)); + assert_eq!(s("x"), None); + assert_eq!(u("65537"), Some(0.05)); + assert_eq!(u("65535"), Some(3276.75)); + assert_eq!(u("12pt"), Some(12.0)); + assert_eq!(u("-20"), Some(-1.0)); + } + #[test] fn measures() { assert_eq!(measure("1440", 20.0), Some(72.0)); diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 68caa1f5be..df7e28fdff 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -40,6 +40,7 @@ mod strict_drawings; mod text_alignment; mod text_boxes; mod toc_ranges; +mod twips_wrap; mod undefined_style; mod vml_shapes; mod word_xml; diff --git a/crates/docx/tests/it/twips_wrap.rs b/crates/docx/tests/it/twips_wrap.rs new file mode 100644 index 0000000000..a560070428 --- /dev/null +++ b/crates/docx/tests/it/twips_wrap.rs @@ -0,0 +1,55 @@ +//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low +//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a +//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent. +//! Fixtures are hand-written. + +use wordcraft_doc::Document; +use wordcraft_doc::props::ParaProps; + +use crate::fixtures::zip; + +/// A package whose body is `body`. +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) +} + +fn props(body: &str) -> Vec { + let d: Document = wordcraft_docx::read(&docx(body)).unwrap(); + d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect() +} + +#[test] +fn indents_beyond_16_bits_wrap() { + let p = props( + r#"a + b + c + d"#, + ); + assert_eq!(p[0].indent_left, Some(36.0)); + assert_eq!(p[1].indent_right, Some(72.0)); + // 65556 -> 20 twips; -65516 -> 20 twips. + assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0))); + // 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360. + assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0))); +} + +#[test] +fn spacing_beyond_16_bits_wraps() { + let p = props(r#"a"#); + assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0))); +} + +#[test] +fn in_range_and_unit_values_are_unchanged() { + let p = props( + r#"a + b"#, + ); + assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75))); + assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0))); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f0022bfe8b..76ec3c9bfd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -683,3 +683,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. | | 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. | | 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). | +| 2026-11-04 | trivial | DOCX: paragraph and list-level indents (`w:ind`) and paragraph spacing (`w:spacing` before/after) written as plain twips beyond 16 bits keep their low 16 bits, as Word reads them (`w:left="66256"` is a 36 pt indent), instead of pushing the text off the page (`twips_wrap.rs`, P0059) | From d34e341d752479abfc49d283dde5e7360fcfdb5a Mon Sep 17 00:00:00 2001 From: keithcsqueensu <92232107+keithcsqueensu@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:41:38 -0500 Subject: [PATCH 36/47] Charts: an automatic series colour follows the series' c:idx (#5963) (#5975) * Charts: an automatic series colour follows the series' c:idx (#5963) A series without its own colour took the accent for its place in the file. Word picks it by the series' c:idx (c:order doesn't matter), so a chart whose series aren't numbered 0, 1, 2... in file order, such as a lone series with idx 2 after a deletion, got different colours. c:idx is read strictly as the unsignedInt the schema says; a missing or invalid value falls back to the place in the file. * Charts: the renumbered test helper fails if ser's layout changes (#5963) --------- Co-authored-by: kiwi4boy Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/docx/src/read/chart/model.rs | 3 ++ crates/docx/src/read/chart/parse.rs | 8 +++- crates/docx/src/read/chart/tests.rs | 62 +++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 1 + 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7b8060a4bb..171610e894 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -884,6 +884,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | | 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) | | 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style | +| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) | | 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/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs index a3ee99637a..71ee7040f6 100644 --- a/crates/docx/src/read/chart/model.rs +++ b/crates/docx/src/read/chart/model.rs @@ -66,6 +66,9 @@ pub(super) struct Axis { pub(super) struct Series { pub(super) name: String, + /// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None` + /// when missing or not a valid number. + pub(super) idx: Option, pub(super) color: Option, pub(super) line_w: f32, /// Whether the series is drawn as a line (scatter series without one get markers). diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs index 737effde32..3c09fcfe9d 100644 --- a/crates/docx/src/read/chart/parse.rs +++ b/crates/docx/src/read/chart/parse.rs @@ -91,8 +91,11 @@ impl Chart { self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK) } + /// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order` + /// doesn't matter), else the one its place in the file picks. pub(super) fn series_colour(&self, j: usize) -> Rgb { - self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j)) + let s = self.series.get(j); + s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j))) } /// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or @@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 { impl Series { fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series { let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default(); + let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::().ok()).and_then(|v| usize::try_from(v).ok()); let sp = ser.child("c:spPr"); let ln = sp.and_then(|p| p.child("a:ln")); let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme)); @@ -219,7 +223,7 @@ impl Series { } } let labels = show_values(ser.child("c:dLbls")); - Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() } + Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() } } } diff --git a/crates/docx/src/read/chart/tests.rs b/crates/docx/src/read/chart/tests.rs index 4e064e9a85..b943f1cfd5 100644 --- a/crates/docx/src/read/chart/tests.rs +++ b/crates/docx/src/read/chart/tests.rs @@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() { assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00"); assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04"); } + +// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as +// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and +// `c:order` changes nothing. + +/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches. +fn column_colours(sers: &[String]) -> (Vec, Vec) { + let plot = format!( + r#"{}"#, + sers.concat() + ); + let items = with_legend(&plot, "r", 300.0, 200.0); + let bars = filled(&items).iter().map(|b| b.0).collect(); + let keys = items + .iter() + .filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None }) + .collect(); + (bars, keys) +} + +/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them). +fn renumbered(ser: &str, idx_order: &str) -> String { + let start = ser.find("").expect("ser writes c:tx"); + format!("{}{idx_order}{}", &ser[..start], &ser[end..]) +} + +#[test] +fn automatic_series_colours_follow_c_idx() { + let accent = |k: usize| theme()[4 + k]; + let a = ser(0, "A", None, &["Q1"], &["1"]); + let b = ser(1, "B", None, &["Q1"], &["2"]); + // idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend. + let sers = [renumbered(&a, r#""#), renumbered(&b, r#""#)]; + assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)])); + // A lone series with idx 2 is accent3. + let lone = [renumbered(&a, r#""#)]; + assert_eq!(column_colours(&lone).0, [accent(2)]); + // c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped. + let swapped = [renumbered(&a, r#""#), renumbered(&b, r#""#)]; + assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]); + // A series' own colour still wins. + let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#""#)]; + assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]); +} + +#[test] +fn series_without_a_usable_c_idx_take_their_place() { + let accent = |k: usize| theme()[4 + k]; + let a = ser(0, "A", None, &["Q1"], &["1"]); + let b = ser(1, "B", None, &["Q1"], &["2"]); + // No c:idx at all: the place in the file. + assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]); + // Junk, negative and out-of-range values: no panic, the place in the file. + for junk in ["x", "-1", "1e3", "99999999999999999999999"] { + let sers = [renumbered(&a, &format!(r#""#)), renumbered(&b, &format!(r#""#))]; + assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}"); + } + // The largest unsignedInt `c:idx` allows draws, without a panic. + let big = [renumbered(&a, &format!(r#""#, u32::MAX))]; + assert_eq!(column_colours(&big).0.len(), 1); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 76ec3c9bfd..f1b6f49283 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -133,7 +133,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Text-box text direction is read as Word reads it: `mso-layout-flow-alt:bottom-to-top` as `vert270` whatever `layout-flow` says, else `layout-flow:vertical` or `vertical-ideographic` as `vert`, else horizontal (#5193). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | -| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | +| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a series without its own colour takes the accent its `c:idx` picks, as Word does (#5963); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | @@ -335,6 +335,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | Date | Change | Summary | |---|---|---| | 2026-11-08 | trivial | XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897) | +| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) | | 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. | diff --git a/docs/gaps.md b/docs/gaps.md index b5264cd4f1..af38e066de 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -208,6 +208,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 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-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. | +| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) | | 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 | From fde39ab0615d9153db933d11752b8d0fc4505e30 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 02:44:01 -0300 Subject: [PATCH 37/47] Desktop: constrain newly opened window dimensions to available screen width (#5871) (#5876) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + apps/wordcraft/src/main.rs | 2 + crates/ui-egui/src/window_geometry.rs | 60 ++++++++++++++++++++++- crates/ui-egui/src/windows.rs | 38 ++++++++++++++- crates/ui-egui/src/windows/tests.rs | 68 +++++++++++++++++++++++++++ docs/hardware-parity.md | 3 +- 6 files changed, 169 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 171610e894..203f74a3c3 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-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. - 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged. - 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged. +- 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871). - 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. diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 1c29e22899..83dc231769 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -69,6 +69,8 @@ impl eframe::App for App { if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); + let b = self.0.window_builder().clone().with_inner_size(size); + self.0.set_window_builder(b); } // The geometry saved for the next start is the active window's. let prev = self.0.ui.window; diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index 142ffb0a83..662c138ded 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -70,7 +70,7 @@ const MAX_SIZE: egui::Vec2 = egui::vec2(16384.0, 16384.0); const MAX_COORD: f32 = 32768.0; /// Room a window leaves on each side of its monitor for the taskbar, the Dock or a desktop panel, /// which the monitor size (all egui reports) includes. -const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0); +pub const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0); /// How much of a window's top edge must be on one monitor for the pointer to drag it back. const GRIP: egui::Vec2 = egui::vec2(96.0, 16.0); @@ -234,6 +234,30 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option { Some(size.max(MIN_SIZE).min(max)) } +/// Clamps an inner window size so that the window (including decoration `frame`) fits on `monitor_size` +/// leaving room on each side for system bars ([`SYSTEM_BAR`]), bounded below by [`MIN_SIZE`] (#5871). +pub fn fit_inner_size(inner: egui::Vec2, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::Vec2 { + if !inner.is_finite() || inner.x <= 0.0 || inner.y <= 0.0 { + return MIN_SIZE; + } + if !monitor_size.is_finite() || monitor_size.x <= 0.0 || monitor_size.y <= 0.0 { + return inner.max(MIN_SIZE); + } + let frame = if frame.is_finite() { frame.max(egui::Vec2::ZERO) } else { egui::Vec2::ZERO }; + let room = (monitor_size - 2.0 * SYSTEM_BAR).max(MIN_SIZE); + let max = (room - frame).max(MIN_SIZE); + inner.min(max).max(MIN_SIZE) +} + +/// Returns a copy of `builder` whose requested inner size is clamped to fit within `monitor_size` +/// (including decoration `frame`) with room for system bars ([`SYSTEM_BAR`]) (#5871). +pub fn fit_builder_to_monitor(builder: egui::ViewportBuilder, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::ViewportBuilder { + match builder.inner_size { + Some(inner) => builder.with_inner_size(fit_inner_size(inner, monitor_size, frame)), + None => builder, + } +} + #[cfg(test)] mod tests { use super::*; @@ -567,4 +591,38 @@ mod tests { // Wayland reports no rects. assert_eq!(system.fit_on_monitor(&RawWindowState { monitor_size: Some(laptop), ..Default::default() }), None); } + + /// On displays with smaller dimensions or high DPI scaling (e.g. 2560x1440 at 200% scale, + /// yielding a 1280x720 pt screen), the requested inner size shrinks to fit the screen + /// rather than extending past the boundaries (#5871). + #[test] + fn fit_inner_size_constrains_window_to_available_screen_dimensions() { + let default_inner = egui::vec2(1440.0, 920.0); + let frame = egui::vec2(16.0, 39.0); + + // 2560x1440 display with 200% scaling: 1280x720 pt. + let scaled_1440p = egui::vec2(1280.0, 720.0); + let fitted = fit_inner_size(default_inner, scaled_1440p, frame); + assert_eq!(fitted, egui::vec2(1168.0, 585.0)); + assert!(fitted.x + frame.x <= scaled_1440p.x); + assert!(fitted.y + frame.y <= scaled_1440p.y); + + // Standard 2560x1440 at 100% scaling: ample room, size remains untouched. + let large_monitor = egui::vec2(2560.0, 1440.0); + assert_eq!(fit_inner_size(default_inner, large_monitor, frame), default_inner); + + // Tiny display: clamps to minimum inner size. + let tiny = egui::vec2(600.0, 400.0); + assert_eq!(fit_inner_size(default_inner, tiny, frame), MIN_SIZE); + + // Hostile / non-finite inputs never panic and fall back safely. + assert_eq!(fit_inner_size(egui::vec2(f32::NAN, 100.0), scaled_1440p, frame), MIN_SIZE); + assert_eq!(fit_inner_size(default_inner, egui::vec2(f32::INFINITY, f32::NAN), frame), default_inner.max(MIN_SIZE)); + + // Viewport builder adaptation preserves other builder properties while clamping inner size. + let builder = egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_title("Test"); + let adapted = fit_builder_to_monitor(builder, scaled_1440p, frame); + assert_eq!(adapted.inner_size, Some(egui::vec2(1168.0, 585.0))); + assert_eq!(adapted.title, Some("Test".into())); + } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 9e60e318d7..421a3428ab 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -307,6 +307,11 @@ impl WordApp { self.windows.builder = builder; } + /// The viewport builder that new windows start from. + pub fn window_builder(&self) -> &egui::ViewportBuilder { + &self.windows.builder + } + /// Whether a close request for the root viewport closes only the window drawn there: true /// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]), /// and the host keeps the app running. @@ -597,7 +602,38 @@ impl WordApp { let mut list = std::mem::take(&mut self.windows.list); self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect(); let titles: HashMap = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect(); - let base = self.windows.builder.clone(); + let target_monitor = list + .recent() + .find_map(|id| { + ctx.input_for(viewports.viewport_id(id), |i| { + let m = i.viewport().monitor_size?; + (m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m) + }) + }) + .or_else(|| { + ctx.input(|i| { + let m = i.viewport().monitor_size?; + (m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m) + }) + }); + let frame = list + .recent() + .find_map(|id| { + ctx.input_for(viewports.viewport_id(id), |i| { + let v = i.viewport(); + let (outer, inner) = (v.outer_rect?, v.inner_rect?); + if !outer.is_finite() || !inner.is_finite() { + return None; + } + Some((outer.size() - inner.size()).max(egui::Vec2::ZERO)) + }) + }) + .filter(|f| f.is_finite()) + .unwrap_or_else(|| egui::vec2(16.0, 39.0)); + let base = match target_monitor { + Some(mon) => crate::window_geometry::fit_builder_to_monitor(self.windows.builder.clone(), mon, frame), + None => self.windows.builder.clone(), + }; self.windows.showing = true; let events = viewports.show( ui, diff --git a/crates/ui-egui/src/windows/tests.rs b/crates/ui-egui/src/windows/tests.rs index e78158d18b..fd038cb751 100644 --- a/crates/ui-egui/src/windows/tests.rs +++ b/crates/ui-egui/src/windows/tests.rs @@ -21,6 +21,8 @@ thread_local! { static UPLOADS: RefCell = const { RefCell::new(0) }; /// Each viewport's last input-method output. static IME: RefCell>> = RefCell::new(HashMap::new()); + /// Viewport builders passed to immediate viewports, by viewport id. + static BUILDERS: RefCell> = RefCell::new(HashMap::new()); } fn input(id: ViewportId) -> RawInput { @@ -49,10 +51,12 @@ fn backend() -> egui::Context { EVENTS.with(|e| e.borrow_mut().clear()); COMMANDS.with(|c| c.borrow_mut().clear()); UPLOADS.with(|u| *u.borrow_mut() = 0); + BUILDERS.with(|b| b.borrow_mut().clear()); let ctx = egui::Context::default(); ctx.set_embed_viewports(false); egui::Context::set_immediate_viewport_renderer(|ctx, viewport| { let id = viewport.ids.this; + BUILDERS.with(|b| b.borrow_mut().insert(id, viewport.builder.clone())); let mut raw = input(id); raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); let mut cb = viewport.viewport_ui_cb; @@ -630,3 +634,67 @@ fn every_open_replaces_an_untouched_blank_document_in_its_window() { } std::fs::remove_dir_all(second.parent().unwrap()).ok(); } + +/// Opening a second document when another is already open constrains the new window's requested +/// dimensions so that it fits within the active monitor's bounds, even on displays with high +/// DPI scaling (such as 2560x1440 at 200% scale = 1280x720 pt) where the unconstrained default +/// size (1440x920 pt) would extend past the right edge (#5871). +#[test] +fn newly_opened_window_does_not_exceed_screen_width() { + let doc1 = docx_on_disk("doc1", "Doc One"); + let doc2 = docx_on_disk("doc2", "Doc Two"); + let ctx = backend(); + + // Simulate 2560x1440 at 200% scale (1280x720 pt). + let screen = egui::vec2(1280.0, 720.0); + let frame_decorations = egui::vec2(16.0, 39.0); + INFOS.with(|i| { + let mut infos = i.borrow_mut(); + let root = infos.entry(ViewportId::ROOT).or_default(); + root.monitor_size = Some(screen); + root.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(48.0, 48.0), egui::vec2(1168.0, 585.0))); + root.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(40.0, 17.0), egui::vec2(1184.0, 624.0))); + }); + + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + + // Open first document in the primary window. + a.run("file.open", json!({"path": doc1.to_string_lossy()})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 1); + + // Open second document, creating a second window while the first is active. + a.run("file.open", json!({"path": doc2.to_string_lossy()})).unwrap(); + frames(&ctx, &mut a, 3); + assert_eq!(a.window_count(), 2); + + // Find the immediate viewport created for the new window. + let child_builder = BUILDERS.with(|b| { + b.borrow() + .iter() + .find(|(id, _)| **id != ViewportId::ROOT) + .map(|(_, builder)| builder.clone()) + .expect("child window viewport builder was recorded") + }); + + let inner_size = child_builder.inner_size.expect("child window has an inner size requested"); + assert!(inner_size.x <= 1168.0, "new window width ({}) must not exceed available width on 1280pt monitor (1168pt)", inner_size.x); + assert!( + inner_size.x + frame_decorations.x <= screen.x, + "new window outer width ({} + {}) must fit within screen width ({})", + inner_size.x, + frame_decorations.x, + screen.x + ); + assert!( + inner_size.y + frame_decorations.y <= screen.y, + "new window outer height ({} + {}) must fit within screen height ({})", + inner_size.y, + frame_decorations.y, + screen.y + ); + + std::fs::remove_dir_all(doc1.parent().unwrap()).ok(); + std::fs::remove_dir_all(doc2.parent().unwrap()).ok(); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5e741c2f97..5615e592bd 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with Microsoft Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** 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 (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-07 · **Change:** trivial (newly opened window dimensions are constrained to fit available screen dimensions, #5871); 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 (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -28,6 +28,7 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l | Date | Change | Summary | |---|---|---| +| 2026-11-07 | trivial | Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area, preventing windows from exceeding screen width on scaled displays (#5871) | | 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 | 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. | From 7592d1440ef5fa2b029052c3b5f8b53998205ada Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 00:48:33 -0500 Subject: [PATCH 38/47] fix(docx): refuse to carry parts named like WordCraft's bookkeeping (#5411) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/docx/src/read/embed.rs | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 203f74a3c3..a956d9ab45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -252,6 +252,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) - 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288) - 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged. +- 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index 1cc97bb038..a2a91f4e57 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -239,7 +239,13 @@ impl Reader<'_> { /// Keep one part's bytes and content type (not its relationships). fn keep_part(&mut self, part: &str) -> bool { let lower = part.to_ascii_lowercase(); - if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) { + // `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part. + if !manifest_field_ok(part) + || lower.ends_with(".rels") + || lower == "[content_types].xml" + || lower.starts_with("wordcraft:") + || part.eq_ignore_ascii_case(&self.main) + { log::warn!("docx: not carrying part {part:?} for an embedded object"); return false; } @@ -388,6 +394,23 @@ mod tests { assert!(roots.contains("keep.bin"), "the part is dropped: {roots}"); } + /// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried: + /// kept, it would be read back as that bookkeeping and add relationships the file chose. + #[test] + fn a_part_named_like_bookkeeping_is_not_carried() { + let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n"; + let bytes = package( + r#"text"#, + &[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")], + &[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")], + ); + let doc = crate::read(&bytes).unwrap(); + let roots = unmodelled_roots(&doc); + assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}"); + let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default(); + assert!(!manifest.contains("evil"), "{manifest}"); + } + /// An unqualified attribute is a relationship only where the markup names a relationship /// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own. #[test] From 735c2a5382ac109b2e32e07a99651c91c03a09ad Mon Sep 17 00:00:00 2001 From: David Prieto Date: Sun, 8 Nov 2026 06:59:44 +0100 Subject: [PATCH 39/47] layout: keep post-break anchors with text across a wrapped row (#5937) * layout: keep wrapped-row anchors after page breaks * test: pin wrapped picture drops, history and DOCX * docs: record wrapped-row anchor coverage * docs: log wrapped-row picture anchoring fix --------- Co-authored-by: Martin Ball --- ROADMAP.md | 3 +++ crates/engine/src/cmd/objects/tests.rs | 37 ++++++++++++++++++++++++++ crates/layout/src/floats.rs | 16 ++++++++--- docs/gaps.md | 2 ++ docs/layout-parity.md | 3 +++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a956d9ab45..7532a94f4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +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. +> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates 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. @@ -170,6 +171,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged. - 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged. - 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871). +- 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions. - 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. @@ -887,6 +889,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) | | 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style | | 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) | +| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. 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/engine/src/cmd/objects/tests.rs b/crates/engine/src/cmd/objects/tests.rs index 51d04872d1..13836d60c5 100644 --- a/crates/engine/src/cmd/objects/tests.rs +++ b/crates/engine/src/cmd/objects/tests.rs @@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() { assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect); } +/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right +/// of the same physical row. A drop after the break must still stay on the destination page. +#[test] +fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() { + for gap in [0.0, 6.0, 12.0] { + let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination")); + s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap(); + s.run("layout.break", &json!({"kind": "page"})).unwrap(); + s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + super::select::with_float(&mut s, |f| { + f.dist = gap; + f.dist_top = gap; + f.dist_bottom = gap; + }) + .unwrap(); + let before = serde_json::to_value(&s.doc.body).unwrap(); + let depth = s.undo_depth(); + s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap(); + let hit = object_area(&mut s); + assert_eq!(hit.page, 1, "gap={gap}: {hit:?}"); + assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect); + assert_eq!(s.undo_depth(), depth + 1); + let after = serde_json::to_value(&s.doc.body).unwrap(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after); + let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap()); + let saved = object_area(&mut copy); + assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}"); + assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position"); + } +} + /// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object, /// select it, then resize and move it with `arrange.bounds`. fn click_resize_and_drag(insert: &str, params: Value) { diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 8ef098a44a..25f00370f0 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -438,13 +438,23 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: /// the one holding its anchor, or for anchors on a line after a page or column break that holds /// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the /// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after -/// them they are on the new page with it (#4262). +/// them they are on the new page with it (#4262). The text may be in the same row's +/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107). pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec> { let mut out = vec![None; p.objects.len()]; for (li, l) in pl.lines.iter().enumerate() { let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default(); - let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak)) - && !clusters.iter().any(para::takes_room); + let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak)); + // A wrap can leave only the zero-width anchor in the row's left segment, with its + // text on the right. That is still visible content after the break, not a cover box. + let lead = after_break + && !clusters.iter().any(para::takes_room) + && !pl + .lines + .iter() + .skip(li + 1) + .take_while(|next| next.beside) + .any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room)); for c in clusters { let para::ClKind::Object(oi) = c.kind else { continue }; if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) { diff --git a/docs/gaps.md b/docs/gaps.md index af38e066de..4a4bc71d48 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,6 +5,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); 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: 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. +> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates 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 > @@ -209,6 +210,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. | | 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) | +| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. 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/layout-parity.md b/docs/layout-parity.md index 657952ffb4..1f2f967632 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,8 @@ # Layout and pagination parity with Microsoft Word > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously 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 +> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. + 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, @@ -313,6 +315,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 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-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` | | 2026-11-06 | trivial | Line numbers are laid out with the document's "Line Number" character style over its defaults, as Word draws them, not in a fixed 10 pt grey (`layout::tests::line_numbers_follow_the_line_number_style`) | +| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Estimates unchanged. | | 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 523e319424ff9840b1eb886a273085b2882d0d15 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 01:01:59 -0500 Subject: [PATCH 40/47] fix(formats): parse at most MAX_COLS cells of a LaTeX tabular row (#5415) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/formats/src/latex/table.rs | 4 +++- crates/formats/src/latex/tests.rs | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7532a94f4a..4f6e0327e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -255,6 +255,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288) - 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged. - 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged. +- 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/formats/src/latex/table.rs b/crates/formats/src/latex/table.rs index 97bedc19d1..f02c20cda6 100644 --- a/crates/formats/src/latex/table.rs +++ b/crates/formats/src/latex/table.rs @@ -104,7 +104,9 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable { for r in rows { let mut cells = Vec::new(); let mut g = 0usize; - for cell_toks in r { + // A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is + // walked once per row. + for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) { let mut cell = Cell::default(); let mut content = trim_cell(cell_toks); // `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper diff --git a/crates/formats/src/latex/tests.rs b/crates/formats/src/latex/tests.rs index 4759caad09..3097eaea1f 100644 --- a/crates/formats/src/latex/tests.rs +++ b/crates/formats/src/latex/tests.rs @@ -460,3 +460,14 @@ fn ligature_characters_split_across_runs_stay_apart() { let back = import(tex.as_bytes()); assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}"); } + +#[test] +fn a_tabular_row_keeps_the_cells_a_table_row_holds() { + // A 5,000-cell row with spans widened the multirow tracking every later row walks; the + // document keeps MAX_COLS cells a row, so the rest are left out while parsing. + let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&"); + let toks = tokenize(&format!("{wide}\\\\ a & b \\\\")); + let t = table::table(&toks, "", 0); + assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS)); + assert_eq!(t.rows.get(1).map(Vec::len), Some(2)); +} From e0d8312fbe0410df4581e92bf6403d3afd3e10ca Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 01:17:12 -0500 Subject: [PATCH 41/47] fix(formats): count spreadsheet cell characters as they are added (#5421) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/formats/src/sheet.rs | 28 +++++++++++++++++++++++----- crates/formats/src/sheet/ods.rs | 14 ++++++++------ crates/formats/src/sheet/xlsx.rs | 17 ++++++++++------- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4f6e0327e3..5fbc4d34e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -256,6 +256,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged. - 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged. - 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged. +- 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index beeaa1f142..55079e5e10 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -323,11 +323,13 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option { }) } -/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. -fn push_capped(s: &mut String, t: &str) { - let have = s.chars().count(); - if have < MAX_CELL_CHARS { - s.extend(t.chars().take(MAX_CELL_CHARS - have)); +/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character +/// count, kept up to date here: counting `s` again for every piece of text made a full cell +/// cost its length per further text event. +fn push_capped(s: &mut String, have: &mut usize, t: &str) { + for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) { + s.push(c); + *have += 1; } } @@ -465,6 +467,22 @@ mod tests { ]) } + #[test] + fn a_full_cell_takes_further_text_without_recounting() { + let (mut s, mut n) = (String::new(), 0usize); + push_capped(&mut s, &mut n, "é"); + push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS)); + assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS)); + // A million more text events on the full cell each cost nothing (they used to count it). + let t = std::time::Instant::now(); + for _ in 0..1_000_000 { + push_capped(&mut s, &mut n, "y"); + } + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); + assert_eq!(s.chars().count(), MAX_CELL_CHARS); + assert!(!s.contains('y')); + } + /// #1287: one long shared string in thousands of cells stays under the text budget. #[test] fn xlsx_shared_strings_cannot_blow_up_memory() { diff --git a/crates/formats/src/sheet/ods.rs b/crates/formats/src/sheet/ods.rs index c42df0a356..07df669595 100644 --- a/crates/formats/src/sheet/ods.rs +++ b/crates/formats/src/sheet/ods.rs @@ -11,6 +11,8 @@ struct OCell { kind: String, value: Option, text: String, + /// Characters in `text` (see [`push_capped`]). + text_chars: usize, paras: usize, } @@ -77,7 +79,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, && skip == 0 { if c.paras > 0 { - push_capped(&mut c.text, "\n"); + push_capped(&mut c.text, &mut c.text_chars, "\n"); } c.paras += 1; } @@ -86,17 +88,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, b"s" if in_p > 0 && skip == 0 => { let k = attr(e, b"c").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1_000); if let Some(c) = cell.as_mut() { - push_capped(&mut c.text, &" ".repeat(k)); + push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k)); } } b"tab" if in_p > 0 && skip == 0 => { if let Some(c) = cell.as_mut() { - push_capped(&mut c.text, "\t"); + push_capped(&mut c.text, &mut c.text_chars, "\t"); } } b"line-break" if in_p > 0 && skip == 0 => { if let Some(c) = cell.as_mut() { - push_capped(&mut c.text, "\n"); + push_capped(&mut c.text, &mut c.text_chars, "\n"); } } _ => {} @@ -107,7 +109,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, && skip == 0 && let Some(c) = cell.as_mut() { - push_capped(&mut c.text, &t); + push_capped(&mut c.text, &mut c.text_chars, &t); } } Ev::End(n) => { @@ -181,7 +183,7 @@ fn ods_value(c: &OCell) -> String { ("string", Some(v)) if c.text.trim().is_empty() => { // Attribute text obeys the same per-cell cap as `` text. let mut s = String::new(); - push_capped(&mut s, v); + push_capped(&mut s, &mut 0, v); s } _ => c.text.clone(), diff --git a/crates/formats/src/sheet/xlsx.rs b/crates/formats/src/sheet/xlsx.rs index cca2472119..3e990b2de3 100644 --- a/crates/formats/src/sheet/xlsx.rs +++ b/crates/formats/src/sheet/xlsx.rs @@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result, String /// (`rPh`) left out. fn shared_strings(xml: &[u8]) -> Vec { let mut out = Vec::new(); - let mut cur: Option = None; + let mut cur: Option<(String, usize)> = None; let (mut in_t, mut phonetic) = (false, 0usize); walk(xml, |ev| match ev { Ev::Start(e) => match e.local_name().as_ref() { - b"si" => cur = Some(String::new()), + b"si" => cur = Some((String::new(), 0)), b"t" => in_t = true, b"rPh" => phonetic += 1, _ => {} }, Ev::End(n) => match n.as_slice() { b"si" => { - if let Some(s) = cur.take() + if let Some((s, _)) = cur.take() && out.len() < MAX_SHARED { out.push(s); @@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec { Ev::Text(t) => { if in_t && phonetic == 0 - && let Some(s) = cur.as_mut() + && let Some((s, n)) = cur.as_mut() { - push_capped(s, &t); + push_capped(s, n, &t); } } }); @@ -182,6 +182,9 @@ struct XCell { style: usize, value: String, inline: String, + /// Characters in `value` and `inline` (see [`push_capped`]). + value_chars: usize, + inline_chars: usize, } pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result>, String> { @@ -227,9 +230,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result { if let Some(c) = cell.as_mut() { if in_v { - push_capped(&mut c.value, &t); + push_capped(&mut c.value, &mut c.value_chars, &t); } else if in_is_t && phonetic == 0 { - push_capped(&mut c.inline, &t); + push_capped(&mut c.inline, &mut c.inline_chars, &t); } } } From e76293a56444becfa87dd8c6a607eccdb161c502 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 01:30:15 -0500 Subject: [PATCH 42/47] fix(doc): refuse export links holding characters the writers drop (#5426) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/doc/src/link.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 5fbc4d34e0..6545828ecf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -257,6 +257,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged. - 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged. - 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged. +- 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 5ef9e0e752..0dc4b17694 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool { /// names another machine — following it on Windows leaks the user's NTLM credentials — so it is /// refused too. pub fn is_safe_href(url: &str) -> bool { + // Exporters drop other control characters and noncharacters when they write the link + // (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it + // stands and written as something else. + if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) { + return false; + } let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect(); // `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does. if cleaned.replace('\\', "/").starts_with("//") { @@ -200,6 +206,10 @@ mod tests { "\\\\attacker.example\\share\\x", "/\\attacker.example\\x", " //attacker.example/x", + // Characters the exporters drop on writing don't hide a scheme. + "file\u{85}:///etc/passwd", + "java\u{9F}script:alert(1)", + "/\u{FFFE}/attacker.example/x", "", ] { assert!(!is_safe_href(bad), "{bad:?}"); From 4afff61f10978d885cd80cb38034e797304ae33d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 01:45:02 -0500 Subject: [PATCH 43/47] fix(geom): measure PNGs from their chunk headers, not the decoder's header read (#5459) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/geom/src/picture.rs | 96 ++++++++++++++++++++++++++++++++++-- crates/layout/src/contour.rs | 5 +- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6545828ecf..7986620096 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -258,6 +258,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged. - 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged. - 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged. +- 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/geom/src/picture.rs b/crates/geom/src/picture.rs index ea3a7d8204..25bc311970 100644 --- a/crates/geom/src/picture.rs +++ b/crates/geom/src/picture.rs @@ -51,6 +51,8 @@ pub struct Probe<'a> { pub width: u32, pub height: u32, peak: u64, + /// Whether a PNG has transparency, read from its chunk headers; `None` for other formats. + png_alpha: Option, } /// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both @@ -72,9 +74,48 @@ pub fn probe(bytes: &[u8]) -> Result, Refusal> { } _ => return Err(Refusal::NotAPicture), }; + if format == ImageFormat::Png { + let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?; + let (width, height, peak) = sized(width, height, native, 0)?; + return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) }); + } let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?; let (width, height, peak) = measure(&decoder, held)?; - Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak }) + Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None }) +} + +/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8, +/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and +/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile +/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured. +fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> { + let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes); + // The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type. + if bytes.get(12..16) != Some(b"IHDR".as_slice()) { + return None; + } + let (width, height) = (be(16)?, be(20)?); + let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?); + let mut trns = false; + let mut at = 8usize; + loop { + let len = usize::try_from(be(at)?).ok()?; + match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? { + b"IDAT" => break, + b"IEND" => return None, + b"tRNS" => trns = true, + _ => {} + } + at = at.checked_add(12)?.checked_add(len)?; + } + let (channels, alpha) = match color { + 0 => (1 + u64::from(trns), trns), + 2 | 3 => (3 + u64::from(trns), trns), + 4 => (2, true), + 6 => (4, true), + _ => return None, + }; + Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha)) } /// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds @@ -86,16 +127,20 @@ pub fn probe_dib(dib: Vec) -> Result, Refusal> { let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?; measure(&decoder, 0)? }; - Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak }) + Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None }) } /// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output. fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> { let (width, height) = decoder.dimensions(); + sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held) +} + +/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]). +fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> { if width == 0 || height == 0 { return Err(Refusal::Broken("it has no pixels".into())); } - let native = u64::from(decoder.color_type().bytes_per_pixel()); let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?; Ok((width, height, peak)) } @@ -106,6 +151,18 @@ impl Probe<'_> { self.peak } + /// Whether the picture has transparency, from its header: a PNG's from its chunk headers + /// (an ICC profile isn't inflated to say), another format's from its decoder. + pub fn has_alpha(&self) -> bool { + if let Some(alpha) = self.png_alpha { + return alpha; + } + if self.dib { + return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha()); + } + image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha()) + } + /// Whether decoding fits `budget` bytes. pub fn fits(&self, budget: u64) -> bool { self.peak <= budget @@ -305,6 +362,39 @@ mod tests { !c } + /// `png` with a chunk of `kind` holding `data` placed after IHDR. + fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec { + let mut chunk = (data.len() as u32).to_be_bytes().to_vec(); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(data); + let crc = crc32(&chunk[4..]); + chunk.extend_from_slice(&crc.to_be_bytes()); + let mut out = png.get(..33).unwrap().to_vec(); + out.extend(chunk); + out.extend_from_slice(&png[33..]); + out + } + + #[test] + fn a_png_is_measured_from_its_chunk_headers() { + // An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk + // (the decoder refused it while reading the header) still measures, and isn't decoded. + let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png); + let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF"); + let p = probe(&icc).unwrap(); + assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8)); + assert!(!p.has_alpha()); + assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it"); + // Transparency: an alpha channel, or tRNS before the image data. + assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha()); + let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]); + let p = probe(&trns).unwrap(); + assert!(p.has_alpha()); + assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees"); + // No image data: broken. + assert!(probe(&rgb[..33]).is_err()); + } + #[test] fn small_pictures_decode_and_peaks_count_every_buffer() { let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png); diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index cddb7581b7..90285ecb52 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -286,16 +286,13 @@ impl Alphas { /// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded` /// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing. fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result, OverBudget> { - use image::ImageDecoder; let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) }; // Formats that can't be transparent aren't decoded. if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE { return Ok(None); } // Only the header is read here (a PNG's `tRNS` counts as transparency). - let has_alpha = - image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha()); - if !has_alpha { + if !pic.has_alpha() { return Ok(None); } let pixels = u64::from(pic.width) * u64::from(pic.height); From 04217419b67eeda2443a5283cdc5846091bb44ea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 01:59:33 -0500 Subject: [PATCH 44/47] DOCX save: collect an embedded object's relationship ids in a set (#5414) * fix(docx): collect an embedded object's relationship ids in a set on save * style: rustfmt --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/docx/src/write/embed.rs | 30 +++++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7986620096..4847a9160d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -259,6 +259,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged. - 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged. - 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged. +- 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 19154a8790..d8f40a209e 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -353,11 +353,11 @@ impl super::Writer<'_> { }; self.embeds.markup_bytes = left; let mut root = xml::parse(src.xml.as_bytes()).ok()?; - let mut used = Vec::new(); + let mut used = HashSet::new(); rel_attr_values(&root, &mut used, 0); // Every relationship the markup uses must be writable, or the object would be broken. - let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target))); - if !used.iter().all(writable) { + let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect(); + if !used.iter().all(|id| writable.contains(id.as_str())) { log::warn!("docx: an embedded object's parts are missing; it is left out"); return None; } @@ -638,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) { } } -fn rel_attr_values(e: &El, out: &mut Vec, depth: usize) { +/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup +/// never went through the reader's id limit) costs no scan per attribute. +fn rel_attr_values(e: &El, out: &mut HashSet, depth: usize) { if depth > MAX_DEPTH { return; } for (k, v) in &e.attrs { if xml::is_rel_attr(&e.name, k) && !out.contains(v) { - out.push(v.clone()); + out.insert(v.clone()); } } for c in e.els() { @@ -852,6 +854,24 @@ fn valid_part_name(name: &str) -> String { mod tests { use super::*; + #[test] + fn relationship_ids_are_collected_once_each() { + // A native document's embedded markup skips the reader's id limit: 40,000 distinct ids + // (each named twice) were a scan of every id collected so far, per attribute. + let mut xml = String::from( + r#""#, + ); + for i in 0..40_000 { + xml.push_str(&format!(r#""#)); + } + xml.push_str(""); + let root = xml::parse(xml.as_bytes()).unwrap(); + let mut used = HashSet::new(); + rel_attr_values(&root, &mut used, 0); + assert_eq!(used.len(), 40_000); + assert!(used.contains("rId39999")); + } + #[test] fn charts_cut_to_the_limits_on_save_say_so_once() { // A model deserialised as is can hold more than a chart part keeps; the save left the rest From 661a790540de039b6902b5cc9ffe5d49024bf3c7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 02:15:55 -0500 Subject: [PATCH 45/47] Render: skip drawing paths far past every canvas before flattening (#5453) * fix(render): skip drawing paths far past every canvas before flattening * fix(render): refuse paths with non-finite points too --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/render/src/lib.rs | 14 +++++++++++++- crates/render/src/tests.rs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4847a9160d..9ee2b706d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -260,6 +260,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged. - 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged. - 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged. +- 2026-11-03: Security: the renderer leaves out a drawing path reaching more than a million device pixels (64 times the largest canvas) or of no finite size, which the rasteriser would otherwise flatten into points by the billion before clipping it. Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c21f88063e..02010b168d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -842,10 +842,22 @@ fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) { } } +/// Widest path drawn, in device pixels a side: 64 times the largest canvas. The rasteriser +/// flattens a curve into as many points as its size asks for, before clipping, so a path with +/// coordinates of 1e15 (file data in a native document) would take it forever or run out of +/// memory; one this far past every canvas shows nothing a real drawing would. +const MAX_PATH_PIXELS: f64 = 1_048_576.0; + +/// Whether `path` under `view` is small enough to draw ([`MAX_PATH_PIXELS`]) and finite. +fn path_extent_ok(view: Affine, path: &kurbo::BezPath) -> bool { + let b = view.transform_rect_bbox(path.bounding_box()); + path.is_finite() && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_PATH_PIXELS) +} + fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) { let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return }; let path = wordcraft_layout::display::seg_path(segs); - if path.elements().is_empty() { + if path.elements().is_empty() || !path_extent_ok(fr.view, &path) { return; } ctx.set_transform(fr.view); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index db5e5b0ccf..3efb5fafa5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1411,3 +1411,18 @@ fn a_line_runs_from_top_left_to_bottom_right() { let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None); assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]); } + +#[test] +fn paths_far_past_every_canvas_are_not_flattened() { + let line = |x: f64| { + let mut p = kurbo::BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((x, 0.0), (x, x), (0.0, x)); + p + }; + assert!(path_extent_ok(Affine::IDENTITY, &line(500.0))); + assert!(path_extent_ok(Affine::scale(4.0), &line(200_000.0)), "a large page at a high zoom"); + assert!(!path_extent_ok(Affine::IDENTITY, &line(1e15))); + assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0))); + assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN))); +} From 69d2a2e13187f579a70b58bd8e0a2f95e67e6096 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 15:57:36 +0100 Subject: [PATCH 46/47] docs/gaps: gap 16 RTL in HTML/ODT/RTF landed (#2696) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 4a4bc71d48..a2ba24d360 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -7,7 +7,7 @@ > **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. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (RTL paragraph direction in HTML/ODT/RTF implemented in #2695, pending merge); 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 (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 > > **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. > @@ -103,7 +103,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | -| 16 | **Right-to-left completeness** (letters join across a change of formatting since #4093): kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) | +| 16 | **Right-to-left completeness** (letters join across a change of formatting since #4093): kashida; RTL in HTML/ODT/RTF implemented in #2695 (pending merge; the estimate drops from 5–9 to 4–8 once it merges); six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | @@ -325,6 +325,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-18 | trivial | Gap 8 narrowed: German text hyphenates by the TeX German patterns (reformed spelling); German spelling and grammar remain | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | +| 2026-10-17 | trivial | #16: RTL paragraph direction (`w:bidi`) is implemented for HTML/ODT/RTF in #2695 (pending merge) (`dir="rtl"` / CSS `direction:rtl`, `style:writing-mode="rl-tb"`, `\rtlpar`); kashida and the six old RTL reports remain open | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged | | 2026-10-17 | trivial | #1327: Restrict Editing exceptions no longer let table, row or cell formatting change protected tables; formatting-only protection refuses Paste Formatting, table shading/borders/alignment/margins/text direction, table-style edits and locked table styles; recipient-list commands no longer rewrite merge-field results in a protected document. No gap row (enforcement fix); estimates unchanged | From 02e74a5d9cea0aed6d1f76348c3b4c12b5d6ede9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 8 Nov 2026 16:13:33 +0100 Subject: [PATCH 47/47] Add Delete Previous Sentence with Ctrl+Shift+Backspace (#5094) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- ROADMAP.md | 3 + crates/engine/src/cmd/text.rs | 3 +- crates/engine/src/cmd/text/delete.rs | 17 ++++++ crates/engine/src/tests/typing.rs | 82 ++++++++++++++++++++++++++++ crates/ui-egui/src/keys/tests.rs | 21 +++++++ docs/roadmap.md | 5 +- docs/typing-parity.md | 20 ++++++- 7 files changed, 147 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 36e4f179f6..2eaf953f87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # WordCraft roadmap +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged) > **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. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -280,6 +281,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-31: Table of contents: with Word's table over entries in columns, where the title sits in a one-column section above a continuous break, Insert Table of Contents with new settings keeps the title above the break and the entries in their columns (the new title went under the break, into the columns), and Remove Table of Contents takes the title, the blank paragraph and its break with the entries, as Word does (observed in Word for Mac); a paragraph of another style above the break stays (#4041). - 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097). - 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960). +- **2026-10-31 (#3693, editing extension):** Delete Previous Sentence is a headless registry command and Mod+Shift+Backspace shortcut, using existing sentence boundaries and deletion/undo/protection paths. Paragraph and cell boundaries are preserved. Additional WordCraft behavior; Word-parity counts and estimates unchanged. - 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations. - 2026-10-28: Word count counts each Chinese and Japanese character as a word, as Word does, in the status bar, the Word Count dialog and NUMWORDS fields. Unspaced Chinese text used to count as one word per run of text; Korean, which is written with spaces, still counts by words. (#4547) - 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764). @@ -967,6 +969,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) | | 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) | | 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) | +| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. | | 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) | | 2026-10-29 | trivial | Progress log: documents with links print (#4734) | | 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c554cc885f..a62b0b6f25 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -14,7 +14,7 @@ mod enter_tab; mod unicode; use autoformat::insert; -use delete::{backspace, delete, delete_word_back, delete_word_fwd}; +use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd}; pub use enter_tab::MAX_TYPED_PARAGRAPHS; use enter_tab::{back_tab, new_paragraph, tab}; pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages}; @@ -36,6 +36,7 @@ pub fn specs() -> Vec { CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"), CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"), CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"), + CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"), CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"), CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"), CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"), diff --git a/crates/engine/src/cmd/text/delete.rs b/crates/engine/src/cmd/text/delete.rs index 5c692e523f..bb78661566 100644 --- a/crates/engine/src/cmd/text/delete.rs +++ b/crates/engine/src/cmd/text/delete.rs @@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult { crate::cmd::controls::restore_placeholder(s)?; sel_result(s) } + +/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary. +/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary. +pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult { + if !s.sel.is_collapsed() { + return backspace(s, v); + } + let mut f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + f.off = para.clamp(f.off); + let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) }; + let start = para.sentence_at(previous).0; + s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f }; + delete_selection(s)?; + crate::cmd::controls::restore_placeholder(s)?; + sel_result(s) +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index e2594492dc..350f8872d6 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() { assert_eq!(s.doc, before); } +// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour. +#[test] +fn delete_previous_sentence_respects_caret_boundaries_and_unicode() { + for (text, before, expected) in [ + ("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "), + ("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."), + ("First sentence. Second sentence.", "First sentence. ", "Second sentence."), + ("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "), + ("No punctuation here", "No punctuation", " here"), + ("One. Two.", "", "One. Two."), + ("", "", ""), + ] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": text, "raw": true})); + let mut pos = s.sel.focus.clone(); + pos.off = before.len(); + s.sel = crate::Selection::caret(pos); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}"); + if expected != text { + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected); + } + } +} + +#[test] +fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let count = s.doc.body.len(); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.body.len(), count); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); + s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } }; + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence."); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); +} + +#[test] +fn delete_previous_sentence_tracks_changes_and_respects_read_only() { + for accept in [true, false] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.deleteSentenceBack", json!({})); + let changes = s.run("review.changes", &json!({})).unwrap(); + assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete")); + run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." }); + } + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + assert!(s.run("text.deleteSentenceBack", &json!({})).is_err()); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this."); +} + +#[test] +fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true})); + run(&mut s, "text.tab", json!({})); + run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let before = s.doc.plain_text(StoryRef::Body); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), before); + s.sel = crate::Selection::caret(end); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, ""); + assert!(s.doc.plain_text(StoryRef::Body).contains("First cell.")); +} + // English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with // `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it. diff --git a/crates/ui-egui/src/keys/tests.rs b/crates/ui-egui/src/keys/tests.rs index 1cd6b4df86..84d572f865 100644 --- a/crates/ui-egui/src/keys/tests.rs +++ b/crates/ui-egui/src/keys/tests.rs @@ -1335,3 +1335,24 @@ fn typing_with_smart_art_selected_types_into_an_item() { let g = wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).expect("still there"); assert_eq!(g.items[0].text, "Ada", "one undo step for the typing"); } + +#[test] +fn delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("text.insert", json!({"text": "First. Second sentence.", "raw": true})).unwrap(); + let mods = if cfg!(target_os = "macos") { + Modifiers { mac_cmd: true, command: true, shift: true, ..Default::default() } + } else { + Modifiers { ctrl: true, command: true, shift: true, ..Default::default() } + }; + assert!(dispatch(&mut app, Key::Backspace, mods)); + assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. "); + app.run("edit.undo", json!({})).unwrap(); + assert!(dispatch(&mut app, Key::Backspace, Modifiers { shift: false, ..mods })); + assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second "); + app.run("edit.undo", json!({})).unwrap(); + app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Shift+Backspace": "select.all"}})); + assert!(dispatch(&mut app, Key::Backspace, mods)); + assert!(!app.session.sel.is_collapsed()); + assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second sentence."); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 0cf0d34197..1028446fab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged); previously minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -49,7 +49,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay |---|---|---|---| | M0 | Skeleton and vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | done | — | | M1 | DOCX I/O | done (first version); real-world corpus not started; charts/SmartArt/OLE dropped | 45–75 h | -| M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h | +| M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four); additional editing command Delete Previous Sentence (#3693), outside the Word-parity count | 5–10 h | | M3 | Insert tab | mostly done; equations done (#191), icons done (#309); charts, SmartArt, 3D, screenshot, online media missing | 50–80 h | | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | | M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | @@ -76,6 +76,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i |---|---|---| | 2026-11-03 | trivial | DOCX text-frame anchor locks survive JSON and two save/reopen cycles, including position and size edits | | 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open | +| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open | | 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index e1b2096742..6ad5a1a3e6 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-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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged); previously 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 @@ -8,6 +8,23 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the overall picture is in [`target-app-parity.md`](target-app-parity.md). +## Additional editing command: Delete Previous Sentence (#3693) + +`text.deleteSentenceBack` is a WordCraft extension requested for Ctrl+Shift+Backspace +(Cmd+Shift+Backspace on macOS), not an observed Word default. It deletes from the caret +back to the start of its sentence; at the start of a sentence it deletes the previous +sentence, including its trailing spaces. It uses the same boundaries as Select Sentence +(`.`, `!`, `?` and following spaces), not a new language-specific sentence segmenter. +An existing selection is deleted normally. At the start of a paragraph or table cell it +does nothing: paragraph marks and cell boundaries are preserved. Deletion uses the normal +undo, tracked-changes and protection paths. The shortcut can be reassigned in Customize Keyboard. + +Evidence: `crates/engine/src/tests/typing.rs::delete_previous_sentence_respects_caret_boundaries_and_unicode`, +`delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections`, +`delete_previous_sentence_tracks_changes_and_respects_read_only`, +`delete_previous_sentence_does_not_cross_a_table_cell_boundary`, and +`crates/ui-egui/src/keys/tests.rs::delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned`. + ## Finding smart-quoted text (#5388) Ordinary Find and Replace treat a keyboard apostrophe (`'`) as a straight or typographic single quote (`‘`, `’`), so a search for `Tau'va` finds `Tau’va` typed with smart quotes. Partial queries, case options and whole-word matching still apply. Wildcards, regular expressions, literal searches (`special: false`) and numeric character codes such as `^u39` remain exact. A curly-quote query also stays exact, and the modifier-letter apostrophe `ʼ` is distinct. Replacement `^&` keeps the original matched spelling. This behavior is covered by original engine and CLI/MCP controls; no fresh Word or native desktop comparison is claimed. @@ -604,6 +621,7 @@ DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run. | 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-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 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) | +| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. | | 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged | | 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). | | 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged |