From 2df2bfc30e6ef2e990e808ad2a797a9dec03dc00 Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:01:58 +0700 Subject: [PATCH] Show fresh SEQ, STYLEREF and REF results in PDF export and printing (#5162) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/references.rs | 4 +- crates/engine/src/cmd/references/fields.rs | 242 +++++++++--- .../engine/src/cmd/references/properties.rs | 17 +- crates/engine/src/cmd/references/tests.rs | 350 ++++++++++++++++++ crates/engine/src/io_ext.rs | 5 + crates/engine/src/print.rs | 6 +- crates/engine/src/session/layout.rs | 11 +- crates/layout/src/styleref.rs | 118 +++++- docs/layout-parity.md | 5 +- 10 files changed, 692 insertions(+), 70 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c26854f281..b65266835f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Print Markup balloon anchors use one object walk per paragraph (#3614)); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (Print Markup comment discovery walks paragraph objects once, #3614); 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: 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-03 · **Change:** trivial (progress log: SEQ, STYLEREF and REF fields show fresh results in output); previously trivial (Print Markup balloon anchors use one object walk per paragraph (#3614)); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (Print Markup comment discovery walks paragraph objects once, #3614); 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: 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result. - 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal. - 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. - 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. @@ -722,6 +723,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | | 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) | diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index e364fa8d74..ae949fa4d2 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -16,7 +16,9 @@ mod properties; mod toc; use fields::caption; -pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq}; +pub use fields::{ + CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq, +}; pub(super) use notes::note_options; use notes::{note, note_refs}; pub use properties::{bookmark_text, date_picture}; diff --git a/crates/engine/src/cmd/references/fields.rs b/crates/engine/src/cmd/references/fields.rs index 36da78aaec..acbaa3a447 100644 --- a/crates/engine/src/cmd/references/fields.rs +++ b/crates/engine/src/cmd/references/fields.rs @@ -1,5 +1,6 @@ use super::properties::{ - bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result, + bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value, + set_summary_property, summary_property, text_result, }; use super::toc::{heading_style_level, update_toc}; use super::{ @@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult { /// the last level-n heading's number, or its count when the heading isn't numbered) and the /// text or number of the nearest paragraph of a style. pub fn update_seq(s: &mut Session) -> Result<(), CmdError> { - let mut headings = [0u32; 9]; - let mut seq: std::collections::HashMap = Default::default(); - // Built on the first STYLEREF (field results don't change what it finds). - let mut styles: Option = None; - for path in s.doc.para_paths(StoryRef::Body) { - let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + // Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number. + let numbers = seq_results(&s.doc); + apply_results(&mut s.doc, numbers)?; + let refs = styleref_results(&s.doc); + apply_results(&mut s.doc, refs) +} + +/// A field's fresh result: its story and paragraph, its object index there and the text. +type FieldResult = (StoryRef, Path, usize, String); + +/// The SEQ numbering so far while the document is walked in order. +#[derive(Default)] +struct SeqCount { + /// How many headings of each level there have been (for `\s`). + headings: [u32; 9], + /// Per sequence: its number, and the heading count it was last restarted at. + seq: std::collections::HashMap, + /// Text boxes already counted: a story shown twice, or inside itself, counts once. + boxes: std::collections::HashSet, + out: Vec, +} + +/// 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). +fn seq_results(doc: &wordcraft_doc::Document) -> Vec { + let mut count = SeqCount::default(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; if let Some(l) = heading_style_level(p) && let Some(k) = (l as usize).checked_sub(1) - && let Some(count) = headings.get_mut(k) + && let Some(n) = count.headings.get_mut(k) { - *count = count.saturating_add(1); + *n = n.saturating_add(1); } - let mut updates: Vec<(usize, String)> = Vec::new(); - for (k, o) in p.objects.iter().enumerate() { - let InlineObject::Field { instr, locked: false, .. } = o else { continue }; - let f = FieldCode::parse(instr); - match f.name.as_str() { - "SEQ" => { - let Some(id) = f.args.first() else { continue }; - let restart = - f.arg('s').and_then(|l| l.parse::().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied(); - let n = seq.entry(id.clone()).or_insert((0, 0)); - if let Some(epoch) = restart - && n.1 != epoch - { - *n = (0, epoch); - } - if let Some(r) = f.arg('r').and_then(|r| r.parse::().ok()) { - n.0 = r; - } else if !f.has('c') { - n.0 = n.0.saturating_add(1); - } - let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default(); - updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) })); - } - "STYLEREF" => { - let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc)); - if let Some(i) = ix.index_of(&path) { - updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))))); + count_seq(doc, StoryRef::Body, &path, p, &mut count); + } + count.out +} + +/// 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() { + for id in o.text_boxes() { + if count.boxes.insert(id) { + let part = StoryRef::Part(id); + for bp in doc.para_paths(part) { + if let Some(boxed) = doc.para(part, &bp) { + count_seq(doc, part, &bp, boxed, count); } } - _ => {} } } - if updates.is_empty() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let f = FieldCode::parse(instr); + if f.name != "SEQ" { continue; } - let para = s.doc.para_mut(StoryRef::Body, &path)?; - for (k, r) in updates { - if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) { - // A computed result is no longer stale: Word's F9 clears `w:dirty` too. - *result = r; - *dirty = false; + let Some(id) = f.args.first() else { continue }; + let restart = f.arg('s').and_then(|l| l.parse::().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied(); + let n = count.seq.entry(id.clone()).or_insert((0, 0)); + if let Some(epoch) = restart + && n.1 != epoch + { + *n = (0, epoch); + } + if let Some(r) = f.arg('r').and_then(|r| r.parse::().ok()) { + n.0 = r; + } else if !f.has('c') { + n.0 = n.0.saturating_add(1); + } + 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)); + } +} + +/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text +/// `doc` holds: the SEQ fields in the headings they find should already show their numbers. +fn styleref_results(doc: &wordcraft_doc::Document) -> Vec { + let mut out = Vec::new(); + // Built on the first STYLEREF. + let mut styles: Option = None; + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + for (k, o) in p.objects.iter().enumerate() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + if FieldCode::parse(instr).name != "STYLEREF" { + continue; } + let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc)); + if let Some(i) = ix.index_of(&path) { + out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))))); + } + } + } + out +} + +/// What Word prints for a `REF` to a bookmark the document doesn't have. +pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found."; + +/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's +/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows); +/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the +/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such +/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result +/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote, +/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its +/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The +/// document's bookmarks are looked up once, and each bookmark's text read once, however many +/// fields refer to it. +fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec { + let mut out = Vec::new(); + let mut starts: std::collections::HashMap = std::collections::HashMap::new(); + for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) { + // The first of two bookmarks with one name, as `bookmark_text` takes. + starts.entry(name).or_insert(start); + } + // A bookmark's text, `None` when it ends in a later paragraph. + let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + for (k, o) in p.objects.iter().enumerate() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + // `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed + // here: those keep their stored result rather than print the paragraph's text. + if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) { + continue; + } + let f = FieldCode::parse(instr); + let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or(""); + let Some(start) = starts.get(target) else { + out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string())); + continue; + }; + let text = texts.entry(target.to_string()).or_insert_with(|| { + let source = if start.story == StoryRef::Body { doc } else { stored }; + bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten() + }); + if let Some(r) = text.clone() { + out.push((StoryRef::Body, path.clone(), k, text_result(instr, r))); + } + } + } + out +} + +/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story +/// order. +fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> { + let mut out = Vec::new(); + for id in doc.parts.keys() { + let story = StoryRef::Part(*id); + for path in doc.para_paths(story) { + let Some(p) = doc.para(story, &path) else { continue }; + for (off, o) in p.objects_with_offsets() { + if let InlineObject::BookmarkStart { name } = o { + out.push((name.clone(), Pos { story, path: path.clone(), off })); + } + } + } + } + out +} + +/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale: +/// Word's F9 clears it too). +fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec) -> Result<(), CmdError> { + for (story, path, k, r) in results { + let para = doc.para_mut(story, &path)?; + if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) { + *result = r; + *dirty = false; } para.touch(); } Ok(()) } +/// `results` less those its fields already show. +fn changed(doc: &wordcraft_doc::Document, mut results: Vec) -> Vec { + results.retain(|(story, path, k, r)| { + !matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r) + }); + results +} + +/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document +/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with +/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and +/// formulas keep their stored results (measured with Word for Windows, printing and exporting +/// fields whose stored results were wrong). The document itself keeps its stored results. +/// `None` when every such field already shows its fresh result, so nothing is copied then. +pub fn output_fields(doc: &wordcraft_doc::Document) -> Option { + // Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field, + // and a REF to a bookmarked caption, show the new number. + let seq = changed(doc, seq_results(doc)); + let mut out = (!seq.is_empty()).then(|| doc.clone()); + if let Some(d) = out.as_mut() { + apply_results(d, seq).ok()?; + } + let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc))); + if !styles.is_empty() { + apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?; + } + let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc)); + if !refs.is_empty() { + apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?; + } + out +} + /// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`). fn has_switch(instr: &str, sw: char) -> bool { instr.split('\\').skip(1).any(|s| { diff --git a/crates/engine/src/cmd/references/properties.rs b/crates/engine/src/cmd/references/properties.rs index 98515ed30d..d146e27b15 100644 --- a/crates/engine/src/cmd/references/properties.rs +++ b/crates/engine/src/cmd/references/properties.rs @@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option { /// the paragraph's end. `None` when there is no such bookmark. pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option { let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?; + bookmark_text_at(doc, name, &start) +} + +/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in? +pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool { + doc.para(start.story, &start.path).is_some_and(|para| { + para.object_offsets() + .into_iter() + .any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)) + }) +} + +/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has +/// looked the document's bookmarks up once. +pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option { let para = doc.para(start.story, &start.path)?; let end = para .object_offsets() .into_iter() .find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)) .unwrap_or(para.len()); - Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string()) + Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string()) } diff --git a/crates/engine/src/cmd/references/tests.rs b/crates/engine/src/cmd/references/tests.rs index e5888772c9..460f5c6610 100644 --- a/crates/engine/src/cmd/references/tests.rs +++ b/crates/engine/src/cmd/references/tests.rs @@ -511,3 +511,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() { let texts: Vec = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect(); assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}"); } + +/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with +/// their fresh results, as Word fills them in when it prints or exports even with "Update fields +/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the +/// editor and the document keep every stored result. +#[test] +fn output_shows_fresh_seq_styleref_and_ref_results() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words")); + s.run("select.text", &json!({"text": "Chapter One"})).unwrap(); + s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap(); + s.run("select.text", &json!({"text": "Target words"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + for (instr, result) in [ + (r#"SEQ Figure \* ARABIC"#, "7"), + (r#"STYLEREF "Heading 1""#, "stale style"), + ("REF Mark \\h", "stale text"), + ("NUMWORDS", "999"), + (r#"SEQ Table \* ARABIC"#, "9"), + ] { + s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + // Stale stored results (inserting a field computes its result), the last field locked. + let stored = ["7", "stale style", "stale text", "999", "9"]; + let last = s.doc.para_paths(StoryRef::Body).pop().unwrap(); + let para = s.doc.para_mut(StoryRef::Body, &last).unwrap(); + let mut stale = stored.iter(); + for o in &mut para.objects { + if let InlineObject::Field { result, locked, .. } = o { + *result = stale.next().unwrap().to_string(); + *locked = stale.len() == 0; + } + } + para.touch(); + let shown = |l: &wordcraft_layout::DocLayout| -> Vec { + l.pages + .iter() + .flat_map(|pg| &pg.items) + .flat_map(|it| match it { + wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(), + _ => vec![], + }) + .collect() + }; + assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]); + assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results"); + assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::>(), stored, "the document keeps them"); + let pdf = s.print_pdf(&json!({})).unwrap().0; + assert!(pdf.len() > 1000); + // Once the fields are current, output copies nothing. + update_seq(&mut s).unwrap(); + update_fields(&mut s).unwrap(); + assert!(output_fields(&s.doc).is_none()); +} + +#[test] +fn output_numbers_text_box_captions_where_their_boxes_are_anchored() { + // Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text + // box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E), + // and a REF to a bookmark in a box shows the box's text as stored. + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#; + let run = |t: &str| format!(r#"{t}"#); + let field = |instr: &str, stored: &str| { + format!( + r#" {instr} {}"#, + run(stored) + ) + }; + let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9")); + let text_box = |inner: String| { + format!( + r##"{inner}"## + ) + }; + let marked = format!(r#"{}"#, seq("B")); + let paras = [ + seq("A"), + format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")), + seq("D"), + format!("{}{}", seq("E"), text_box(seq("F"))), + seq("G"), + format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")), + ]; + let body: String = paras.iter().map(|p| format!("{p}")).collect(); + let document = format!("{body}"); + let root = r#""#; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap(); + } + let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap()); + let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers"); + // The SEQ and REF results in reading order, text boxes where they are anchored. + let mut results = Vec::new(); + out.objects_in_reading_order(&mut |o| { + if let InlineObject::Field { instr, result, .. } = o { + results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone())); + } + }); + let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect(); + assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}"); + let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect(); + assert_eq!(refs, ["B Fig 9"], "{results:?}"); +} + +/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or +/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each +/// REF stored as "stale"), rather than Word's missing-bookmark error. +#[test] +fn output_shows_references_to_bookmarks_outside_the_body() { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; + let mark = |name: &str, id: u32| { + format!(r#"Target {name}"#) + }; + let field = |name: &str| { + format!( + r#" REF {name} \h stale"# + ) + }; + let names = ["HDR", "FTR", "FN", "EN", "CMT"]; + let refs: String = names.iter().map(|n| field(n)).collect(); + let document = format!( + r#"{refs}Body commented"# + ); + let header = format!(r#"{}"#, mark("HDR", 1)); + let footer = format!(r#"{}"#, mark("FTR", 2)); + let footnotes = + format!(r#"{}"#, mark("FN", 3)); + let endnotes = format!(r#"{}"#, mark("EN", 4)); + let comments = format!(r#"{}"#, mark("CMT", 5)); + let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let rels = format!( + r#""# + ); + let root = r#""#; + let files = [ + ("_rels/.rels", root.to_string()), + ("word/_rels/document.xml.rels", rels), + ("word/document.xml", document), + ("word/header1.xml", header), + ("word/footer1.xml", footer), + ("word/footnotes.xml", footnotes), + ("word/endnotes.xml", endnotes), + ("word/comments.xml", comments), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, text) in &files { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap(); + } + let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap()); + let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references"); + let mut results = Vec::new(); + for path in out.para_paths(StoryRef::Body) { + for o in &out.para(StoryRef::Body, &path).unwrap().objects { + if let InlineObject::Field { result, .. } = o { + results.push(result.clone()); + } + } + } + let expected: Vec = names.iter().map(|n| format!("Target {n}")).collect(); + assert_eq!(results, expected); +} + +/// 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. +#[test] +fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() { + let mut s = Session::new(wordcraft_doc::Document::from_text("See ")); + s.run("caret.docEnd", &json!({})).unwrap(); + for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] { + s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + for o in &mut para.objects { + if let InlineObject::Field { result, .. } = o { + *result = "stale".into(); + } + } + para.touch(); + let shown = |l: &wordcraft_layout::DocLayout| -> Vec { + l.pages[0] + .items + .iter() + .flat_map(|it| match it { + wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(), + _ => vec![], + }) + .collect() + }; + assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]); + assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result"); + assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::>(), ["stale", "stale"], "the document keeps it"); +} + +/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't +/// compute, keeps its stored number when printed or exported rather than show the paragraph's +/// text; a plain REF to the same bookmark is filled in. +#[test] +fn output_keeps_paragraph_number_references_as_stored() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs ")); + s.run("select.text", &json!({"text": "Target heading"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"]; + for instr in instrs { + s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let last = s.doc.para_paths(StoryRef::Body).pop().unwrap(); + let para = s.doc.para_mut(StoryRef::Body, &last).unwrap(); + for o in &mut para.objects { + if let InlineObject::Field { result, .. } = o { + *result = "2.1".into(); + } + } + para.touch(); + let out = output_fields(&s.doc).expect("the plain REF changes"); + let results: Vec = out + .para(StoryRef::Body, &last) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect(); + assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]); +} + +/// The results of the fields in the paragraph at `path` of `d`. +fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec { + d.para(StoryRef::Body, path) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect() +} + +/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path. +fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path { + let last = s.doc.para_paths(StoryRef::Body).pop().unwrap(); + let para = s.doc.para_mut(StoryRef::Body, &last).unwrap(); + for o in &mut para.objects { + if let InlineObject::Field { result, .. } = o { + *result = stale.into(); + } + } + para.touch(); + last +} + +/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result +/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to +/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result +/// rather than show the first paragraph's part alone. +#[test] +fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs ")); + let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off }; + s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) }; + s.run("insert.bookmark", &json!({"name": "Span"})).unwrap(); + s.run("select.text", &json!({"text": "Short mark"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "Short"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + for instr in ["REF Span \\h", "REF Short \\h"] { + s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let last = stale_last_paragraph(&mut s, "stored"); + let out = output_fields(&s.doc).expect("the short bookmark's REF changes"); + assert_eq!(results_in(&out, &last), ["stored", "Short mark"]); +} + +/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number, +/// in output and after F9: the numbers are filled in before the style references read them. +#[test] +fn a_style_reference_reads_the_headings_new_sequence_number() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs ")); + s.run("select.text", &json!({"text": "Chapter "})).unwrap(); + s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap(); + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 }); + s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap(); + // Stale stored results: the heading's number 9, the reference "stale". + let heading = wordcraft_doc::Path::top(0); + let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap(); + for o in &mut para.objects { + if let InlineObject::Field { result, .. } = o { + *result = "9".into(); + } + } + para.touch(); + let last = stale_last_paragraph(&mut s, "stale"); + let out = output_fields(&s.doc).expect("the fields change"); + assert_eq!(results_in(&out, &heading), ["1"]); + assert_eq!(results_in(&out, &last), ["Chapter 1"]); + update_seq(&mut s).unwrap(); + assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too"); +} + +/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one +/// bookmark, fields to others, and one to a bookmark the document lacks each show what +/// `bookmark_text` (F9's lookup) gives, or Word's error. +#[test] +fn output_gives_every_reference_its_bookmarks_text() { + let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs ")); + for (text, name) in [("First target", "A"), ("Second target", "B")] { + s.run("select.text", &json!({"text": text})).unwrap(); + s.run("insert.bookmark", &json!({"name": name})).unwrap(); + } + s.run("caret.docEnd", &json!({})).unwrap(); + let targets = ["A", "B", "A", "Missing", "A"]; + for t in targets { + s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let last = s.doc.para_paths(StoryRef::Body).pop().unwrap(); + let para = s.doc.para_mut(StoryRef::Body, &last).unwrap(); + for o in &mut para.objects { + if let InlineObject::Field { result, .. } = o { + *result = "stale".into(); + } + } + para.touch(); + let out = output_fields(&s.doc).expect("the stale results change"); + let results: Vec = out + .para(StoryRef::Body, &last) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect(); + let expected: Vec = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect(); + assert_eq!(results, expected); + assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]); +} diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 6622ae6644..f90645a7c3 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -31,6 +31,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), "png" => Some(render_png(doc, 0, 2.0, opts)), "pdf" => { + // Output shows the fields Word fills in as it prints (see `output_fields`). + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); let pdf = wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, @@ -46,6 +49,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio /// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its /// markup area with the comments in balloons (page images always show tracked changes). pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result, String> { + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }; let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo); let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; diff --git a/crates/engine/src/print.rs b/crates/engine/src/print.rs index 9beb516748..32c620eba9 100644 --- a/crates/engine/src/print.rs +++ b/crates/engine/src/print.rs @@ -219,7 +219,9 @@ impl Session { } // Print Markup (File › Print) decides whether tracked changes and comments print. let markup = crate::cmd::file::export_options(self, v); - let lay = self.export_layout_with(markup.markup); + let out = crate::cmd::references::output_fields(&self.doc); + let doc = out.as_ref().unwrap_or(&self.doc); + let lay = self.export_layout_of(doc, markup.markup); let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; let mut single = v.clone(); single["copies"] = json!(1); @@ -238,7 +240,7 @@ impl Session { links, ..Default::default() }; - let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; + let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; Ok((bytes, plan)) } } diff --git a/crates/engine/src/session/layout.rs b/crates/engine/src/session/layout.rs index 5224d17318..2f773e308f 100644 --- a/crates/engine/src/session/layout.rs +++ b/crates/engine/src/session/layout.rs @@ -161,8 +161,15 @@ impl Session { } /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup - /// area with the comments in balloons. + /// area with the comments in balloons. Fields Word fills in for output show their fresh + /// results ([`crate::cmd::references::output_fields`]). pub fn export_layout_with(&self, markup: bool) -> Arc { + let out = crate::cmd::references::output_fields(&self.doc); + self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup) + } + + /// [`Session::export_layout_with`] of `doc`, this document as it is output. + pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc { let (fn_name, fn_path) = self.resolved_filenames(); let opts = LayoutOptions { view: ViewMode::Print, @@ -172,7 +179,7 @@ impl Session { filepath: Some(Arc::from(fn_path.as_str())), ..Default::default() }; - Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts)) + Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts)) } /// Invalidate the cached layout (fonts changed etc.). diff --git a/crates/layout/src/styleref.rs b/crates/layout/src/styleref.rs index 02a528441f..5f2db6045e 100644 --- a/crates/layout/src/styleref.rs +++ b/crates/layout/src/styleref.rs @@ -149,6 +149,10 @@ pub struct StyleIndex { by_path: HashMap, /// Lower-cased style id or name → (id, character style). styles: HashMap, + /// Paragraph style id → the entries of that style, in order. + para_entries: HashMap>, + /// Character style id → the entries with a run of that style, in order. + char_entries: HashMap>, } fn clean(s: &str) -> String { @@ -167,6 +171,8 @@ impl StyleIndex { let mut ordinals: HashMap = HashMap::new(); let mut entries = Vec::new(); let mut by_path = HashMap::new(); + let mut para_entries: HashMap> = HashMap::new(); + let mut char_entries: HashMap> = HashMap::new(); for path in doc.para_paths(StoryRef::Body) { let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; let style = p.props.style.clone().unwrap_or_else(|| "Normal".to_string()); @@ -206,10 +212,18 @@ impl StyleIndex { } off = end; } - by_path.insert(path.clone(), entries.len()); + let i = entries.len(); + by_path.insert(path.clone(), i); + para_entries.entry(style.clone()).or_default().push(i); + for (cs, _) in &runs { + let list = char_entries.entry(cs.clone()).or_default(); + if list.last() != Some(&i) { + list.push(i); + } + } entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal }); } - StyleIndex { entries, by_path, styles } + StyleIndex { entries, by_path, styles, para_entries, char_entries } } /// The entry of the body paragraph at `path`. @@ -261,28 +275,50 @@ impl StyleIndex { } } + /// The entries `t` matches, in order. + fn matches(&self, t: &Target) -> &[usize] { + let list = match t { + Target::Para(id) => self.para_entries.get(id), + Target::Char(id) => self.char_entries.get(id), + }; + list.map_or(&[], Vec::as_slice) + } + + /// The first entry of `found` (the entries a target matches, in order) in a STYLEREF's search + /// order from `at`, with whether it counts as below the field. From a body paragraph: that + /// paragraph and back to the start, then on from it. From a header or footer: the page's + /// paragraphs (from the last when `last`), then back from the page, then on from it. A binary + /// search over `found`, so a target few or no paragraphs have costs no walk over the body. + fn first_in_order(found: &[usize], n: usize, at: At, last_first: bool) -> Option<(usize, bool)> { + let at_or_before = |k: usize| found.partition_point(|&p| p <= k).checked_sub(1).and_then(|j| found.get(j)).copied(); + let from = |k: usize| found.get(found.partition_point(|&p| p < k)).copied().filter(|&p| p < n); + match at { + At::Body(i) => { + let i = i.min(n.checked_sub(1)?); + at_or_before(i).map(|p| (p, false)).or_else(|| from(i.saturating_add(1)).map(|p| (p, true))) + } + At::Page { first, last } => { + if let Some(l) = last.filter(|&l| first <= l).map(|l| l.min(n.saturating_sub(1))) { + let on = if last_first { at_or_before(l).filter(|&p| p >= first) } else { from(first).filter(|&p| p <= l) }; + if let Some(p) = on { + return Some((p, true)); + } + } + let before = first.min(n).checked_sub(1).and_then(at_or_before); + let after = last.map_or(first, |l| l.saturating_add(1)); + before.map(|p| (p, false)).or_else(|| from(after).map(|p| (p, true))) + } + } + } + /// The field's result. pub fn eval(&self, instr: &str, at: At) -> String { let code = parse(instr); let Some(target) = self.target(&code.style) else { return NO_TEXT.to_string() }; - let n = self.entries.len(); - // Candidates in search order, with whether each one counts as below the field (lazy: - // headers evaluate this for every page on every pass). - let order: Box> = match at { - At::Page { first, last } => { - let on = match last { - Some(l) if first <= l => first..l.min(n.saturating_sub(1)).saturating_add(1), - _ => 0..0, - }; - let after = last.map_or(first, |l| l.saturating_add(1)); - let page: Box> = if code.last { Box::new(on.rev()) } else { Box::new(on) }; - Box::new(page.map(|i| (i, true)).chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true)))) - } - At::Body(i) => Box::new((0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true)))), - }; - let Some((i, text, below)) = order.into_iter().find_map(|(i, below)| self.text_of(i, &target).map(|t| (i, t, below))) else { + let Some((i, below)) = Self::first_in_order(self.matches(&target), self.entries.len(), at, code.last) else { return NO_TEXT.to_string(); }; + let Some(text) = self.text_of(i, &target) else { return NO_TEXT.to_string() }; let Some(e) = self.entries.get(i) else { return NO_TEXT.to_string() }; let mut out = match code.number { Number::Text if code.position => String::new(), @@ -357,6 +393,52 @@ mod tests { assert_eq!(parse("STYLEREF").style, ""); } + /// The search order the index's binary search follows, walked entry by entry as it was before + /// the index: the body paragraph and back, then on; or the page (from its last paragraph when + /// `last_first`), back from it, then on. + fn first_by_walk(found: &[usize], n: usize, at: At, last_first: bool) -> Option<(usize, bool)> { + let order: Vec<(usize, bool)> = match at { + At::Page { first, last } => { + let on: Vec = match last { + Some(l) if first <= l => (first..l.min(n.saturating_sub(1)).saturating_add(1)).collect(), + _ => Vec::new(), + }; + let after = last.map_or(first, |l| l.saturating_add(1)); + let page: Vec = if last_first { on.into_iter().rev().collect() } else { on }; + page.into_iter().map(|i| (i, true)).chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true))).collect() + } + At::Body(i) => (0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true))).collect(), + }; + order.into_iter().find(|(i, _)| *i < n && found.contains(i)) + } + + #[test] + fn the_index_finds_what_walking_the_body_finds() { + // Every placement of matches among up to 7 paragraphs, from every body paragraph and every + // page range (in and past the body), both ways round. + for n in 0..=7usize { + for mask in 0u32..(1 << n) { + let found: Vec = (0..n).filter(|i| mask & (1 << i) != 0).collect(); + let mut ats: Vec = (0..=n + 1).map(At::Body).collect(); + for first in 0..=n + 1 { + ats.push(At::Page { first, last: None }); + for last in 0..=n + 1 { + ats.push(At::Page { first, last: Some(last) }); + } + } + for at in ats { + for last_first in [false, true] { + assert_eq!( + StyleIndex::first_in_order(&found, n, at, last_first), + first_by_walk(&found, n, at, last_first), + "n {n}, found {found:?}, {at:?}, last {last_first}" + ); + } + } + } + } + } + #[test] fn suppress_keeps_the_number() { assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2"); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index e4c1021a6d..c8cd715242 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-03 · **Change:** trivial (Print Markup balloon anchor discovery walks paragraph objects once (#3614)); 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 (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-03 · **Change:** trivial (SEQ, STYLEREF and REF fields show fresh results in output); previously trivial (Print Markup balloon anchor discovery walks paragraph objects once (#3614)); 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 (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, @@ -86,6 +86,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: 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 | @@ -278,6 +279,8 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | Date | Change | Summary | |---|---|---| +| 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 | | 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 |