diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ea61d84303..1771a35fea 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -27,6 +27,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/printing.yml b/.github/workflows/printing.yml index 1a5dad4c80..3bfa5cfc44 100644 --- a/.github/workflows/printing.yml +++ b/.github/workflows/printing.yml @@ -42,6 +42,10 @@ jobs: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: '0' steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -64,6 +68,11 @@ jobs: echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" + # apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026). + - name: Configure apt mirrors + if: runner.os == 'Linux' + uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main + - name: Install Linux build and software-rendering dependencies if: runner.os == 'Linux' run: | @@ -72,12 +81,13 @@ jobs: pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0 with: + toolchain: stable components: rustfmt, clippy targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2 with: save-if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89d3b647b1..0ebfaae45c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: Release on: + # A nightly dry run on main: every release build except macOS, nothing signed or released + # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day. + schedule: + - cron: "0 3 * * *" push: branches: [release] workflow_dispatch: @@ -33,6 +37,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: + dry-run: ${{ github.event_name == 'schedule' }} app-name: wordcraft app-title: WordCraft craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/Cargo.lock b/Cargo.lock index 811ba2040b..653ae11b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1135,7 +1135,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1145,6 +1145,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", @@ -2981,7 +2982,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "base64 0.22.1", "bumpalo", @@ -6806,7 +6807,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.15.1" +version = "0.18.1" dependencies = [ "arboard", "chrono", @@ -6838,13 +6839,15 @@ dependencies = [ "wordcraft-engine", "wordcraft-fonts", "wordcraft-ui-egui", + "x11-dl", "x11rb", + "xkbcommon-dl", "zbus", ] [[package]] name = "wordcraft-cli" -version = "0.15.1" +version = "0.18.1" dependencies = [ "image", "serde_json", @@ -6860,11 +6863,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.15.1" +version = "0.18.1" [[package]] name = "wordcraft-doc" -version = "0.15.1" +version = "0.18.1" dependencies = [ "base64 0.23.1", "log", @@ -6878,7 +6881,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.15.1" +version = "0.18.1" dependencies = [ "cfb", "chrono", @@ -6895,7 +6898,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.15.1" +version = "0.18.1" dependencies = [ "aes", "base64 0.23.1", @@ -6920,7 +6923,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.15.1" +version = "0.18.1" dependencies = [ "cfb", "chrono", @@ -6935,6 +6938,7 @@ dependencies = [ "log", "regex", "regex-automata", + "rustix 1.1.5", "serde", "serde_json", "thiserror 2.0.21", @@ -6953,7 +6957,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.15.1" +version = "0.18.1" dependencies = [ "brotli-decompressor", "harfrust 0.13.3", @@ -6967,7 +6971,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.15.1" +version = "0.18.1" dependencies = [ "aes", "base64 0.23.1", @@ -6994,7 +6998,7 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.15.1" +version = "0.18.1" dependencies = [ "image", "serde", @@ -7002,7 +7006,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.15.1" +version = "0.18.1" dependencies = [ "craft-proof", "image", @@ -7022,7 +7026,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.15.1" +version = "0.18.1" dependencies = [ "log", "serde_json", @@ -7034,7 +7038,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.15.1" +version = "0.18.1" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -7042,7 +7046,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.15.1" +version = "0.18.1" dependencies = [ "chrono", "craft-pdf", @@ -7063,7 +7067,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.15.1" +version = "0.18.1" dependencies = [ "image", "log", @@ -7077,7 +7081,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.15.1" +version = "0.18.1" dependencies = [ "accesskit_consumer", "cfb", @@ -7110,7 +7114,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.15.1" +version = "0.18.1" dependencies = [ "craft-web-api", "craft-web-host", @@ -7131,7 +7135,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.15.1" +version = "0.18.1" dependencies = [ "log", "proptest", @@ -7294,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.1" +version = "0.18.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 993a15f583..f6fa8c1f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] -version = "0.15.1" +version = "0.18.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -67,9 +67,9 @@ wuff = "0.2.9" brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -134,7 +134,7 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/README.md b/README.md index 66a7b7776a..908e8ec07b 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,11 @@ XWayland: WORDCRAFT_WAYLAND=1 wordcraft ``` +XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't +install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu, +`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app +opens on native Wayland, whatever the setting says, and logs which ones are missing. + **Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish to the AUR itself. Install one of them: | Package | What it installs | Choose it when | diff --git a/ROADMAP.md b/ROADMAP.md index 258662f793..36984dd36d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,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-08 · **Change:** trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: 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: 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); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: 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: 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); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-04: Three behaviours lost in the #4501 refactor are back (#4683). From the second section on, headers, footers and note separators are measured with their own section's document grid and SECTION/SECTIONPAGES values, not the previous section's. An ODT paragraph default style that names no font no longer clears the document's default font. A press the canvas handles itself (Ctrl+click on a link, switching into a header or footer, equation entry) no longer also shows the link tooltip in that frame. Readiness unchanged. - 2026-11-08: `wordcraft-cli convert --strict` (#6005) and `render --strict` (#6008): a PDF or PNG export (render: the page it writes) refuses, exit 3 and nothing written, when it would draw a character no font has or leave out a picture (missing part, external target, link only, undecodable or unsupported format); the report is on stderr with one JSON line. Engine: `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and `file.exportPng`, and the pure `file.strictReport`. External picture targets are never fetched. The result depends on the host's installed fonts (#5624); readiness estimates unchanged. - 2026-11-05: ODT tables with a usable width keep their column proportions when the file gives only shares (gap 19): LibreOffice's CV template opened and saved with equal columns. - 2026-11-05: ODT font sizes reach Asian and complex-script text (gap 19): Chinese text set to 24 pt stayed at the default size in LibreOffice. @@ -272,6 +273,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged. - 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged. - 2026-11-03: Security: the renderer leaves out a drawing path reaching more than a million device pixels (64 times the largest canvas) or of no finite size, which the rasteriser would otherwise flatten into points by the billion before clipping it. Readiness unchanged. +- 2026-11-03: Security: a picture pasted from the Wayland clipboard is read encoded under the 200 MB paste cap and refused from its header when it is too large, before anything decodes it; arboard decoded and converted it to RGBA with no limits first (as on Windows, #3623). Readiness unchanged. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. @@ -907,6 +909,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Progress log: #4501 refactor regressions restored (#4683) | | 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) | | 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged | | 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 903ba69d5c..a7c38c628c 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use serde_json::{Value, json}; -use wordcraft_engine::Session; +use wordcraft_engine::{CmdError, Session}; const USAGE: &str = "\ wordcraft-cli — WordCraft from the command line @@ -19,7 +19,8 @@ wordcraft-cli — WordCraft from the command line USAGE: wordcraft-cli convert [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc] convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png, - xml); --update-fields refreshes unlocked fields before saving; + xml); --update-fields refreshes unlocked fields before saving + (if protection blocks updates, they are skipped with a warning); an .xml file is a Word XML Document (Flat OPC), or with --format wordml2003 a Word 2003 XML Document; --markup (Print Markup): comments beside each page and @@ -451,6 +452,30 @@ fn report_lines(json: &Value) -> Vec { wordcraft_engine::strict::lines_from_json(json) } +/// Explicitly update unlocked fields in a session before saving the document. +/// +/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to +/// stderr and continues conversion rather than aborting. Other command failures are converted to +/// [`Failure`] via [`engine_error`]. +/// +/// # Arguments +/// +/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed. +/// +/// # Errors +/// +/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`. +fn update_fields(s: &mut Session) -> Result<(), Failure> { + if let Err(e) = s.run("references.updateFields", &json!({})) { + if matches!(e, CmdError::Disabled(_)) { + eprintln!("wordcraft-cli: warning: {e}"); + } else { + return Err(engine_error(e)); + } + } + Ok(()) +} + /// `convert `: `rest` is the command line after `convert`, `open` opens the input. fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result) -> Result<(), Failure> { validate_options("convert", rest, CONVERT_OPTIONS)?; @@ -473,7 +498,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result = out + .para(StoryRef::Body, &Path::top(0)) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.as_str()), + _ => None, + }) + .collect(); + // Result should remain "104" (cached) because the field could not be updated under protection + assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}"); + assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}"); + } + + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 0fd7a423db..c7768b0dae 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -64,6 +64,11 @@ wl-clipboard-rs = "0.9" # Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build # through arboard, at this version. x11rb = "0.13" +# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see +# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at +# these versions and with this feature. +x11-dl = "2.21" +xkbcommon-dl = { version = "0.4.2", features = ["x11"] } [target.'cfg(target_os = "windows")'.dependencies] # The upstream default-apps button opens Windows Settings; printing uses craft-print instead. diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index 92a4a4d254..bb33fd9665 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -180,8 +180,8 @@ pub fn formats() -> Vec { /// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded /// picture in a format arboard doesn't decode. A picture that is there but can't be read says -/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked -/// from the header before anything is decoded (#3623). +/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are +/// checked from the header before anything is decoded (#3623). pub fn picture() -> Option { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -208,6 +208,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option { /// The picture arboard decodes from the clipboard, else an encoded one read past it. #[cfg(not(target_os = "windows"))] fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option { + #[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))] + if let Some(picture) = platform::picture() { + return Some(picture); + } match clipboard.get_image() { Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }), Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded), @@ -232,6 +236,7 @@ mod platform { use std::io::Read; use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered}; + use wordcraft_ui_egui::paste_picture::ClipboardPicture; fn wayland() -> bool { std::env::var_os("WAYLAND_DISPLAY").is_some() @@ -488,6 +493,22 @@ mod platform { } } + /// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from + /// its header when a paste can't take its size, before anything decodes it. arboard decodes + /// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture + /// (or isn't reachable): arboard is asked then, which also covers X11. + pub fn picture() -> Option { + if !wayland() { + return None; + } + let offered = formats(); + let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?; + Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) { + Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes), + None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()), + }) + } + #[cfg(test)] mod tests { use super::*; diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 347f76089a..0e3b7d6b50 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -276,9 +276,16 @@ fn saved_ui() -> Option { /// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from /// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and /// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't -/// available falls back to the other instead of failing to start. +/// available falls back to the other instead of failing to start. The X11 libraries are only +/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when +/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and +/// winit panicked without libxkbcommon-x11. #[cfg(all(unix, not(target_os = "macos")))] -fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option) -> (bool, String) { +fn uses_x11( + setting: wordcraft_ui_egui::DisplayServer, + env: impl Fn(&str) -> Option, + x11_libraries: impl FnOnce() -> Result<(), String>, +) -> (bool, String) { use wordcraft_ui_egui::DisplayServer; let set = |name: &str| env(name).is_some_and(|v| !v.is_empty()); let (wanted, by) = match env("WORDCRAFT_WAYLAND") { @@ -288,17 +295,39 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"), None => (setting, "the setting"), }; - let x11 = wanted != DisplayServer::Wayland && set("DISPLAY"); + let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None }; + let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none(); + let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET"); let runs = if x11 { "X11" - } else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") { + } else if wayland { "native Wayland" } else if set("DISPLAY") { "X11 (no Wayland)" } else { "neither (no Wayland or X server found)" }; - (x11, format!("display: {runs}; {} asks for {}", by, wanted.code())) + let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code()); + if let Some(missing) = missing { + why += &format!("; not X11 because its libraries are missing: {missing}"); + } + (x11, why) +} + +/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11, +/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard. +#[cfg(all(unix, not(target_os = "macos")))] +fn x11_libraries() -> Result<(), String> { + use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb}; + let mut missing: Vec = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()] + .into_iter() + .flatten() + .map(|e| e.to_string()) + .collect(); + if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() { + missing.push("libxkbcommon-x11.so.0 could not be loaded".into()); + } + if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) } } fn save_prefs(app: &WordApp) { @@ -560,7 +589,7 @@ fn main() -> eframe::Result { } #[cfg(all(unix, not(target_os = "macos")))] { - let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name)); + let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries); log::info!("{why}"); if x11 { options.event_loop_builder = Some(Box::new(|b| { @@ -692,7 +721,7 @@ mod tests { fn the_display_server_follows_the_variable_then_the_setting() { use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11}; let x11 = |setting, vars: &[(&str, &str)]| { - uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0 + uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0 }; let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")]; assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop"); @@ -708,10 +737,40 @@ mod tests { let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]; assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only)); // Without Wayland, Wayland isn't forced either: unforced winit picks X11. - let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into())); + let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(())); assert!(!forced && why.contains("X11 (no Wayland)"), "{why}"); } + /// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks + /// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check + /// runs only when X11 would be forced. + #[test] + fn missing_x11_libraries_leave_the_app_on_wayland() { + use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11}; + let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")]; + for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] { + let env = |name: &str| { + both.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| (*v).into()) + .or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into)) + }; + let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into())); + assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}"); + assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}"); + } + let checked = std::cell::Cell::new(false); + let (x11, _) = uses_x11( + Wayland, + |name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()), + || { + checked.set(true); + Ok(()) + }, + ); + assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked"); + } + /// A named pipe or a device under such a name is a warning, not a read: the pipe blocked /// start-up until something wrote to it, and `/dev/zero` read until memory ran out. #[test] diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index e143bd9763..6b3014933e 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> 0 | 1 | 4 => PartKind::Header, _ => PartKind::Footer, }; - let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; if !blocks.is_empty() { let id = doc.add_part(kind, blocks); hf_ids.push((si, k, id)); @@ -436,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m Ok(table::assemble(paras)) } +/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a +/// non-empty header story ends with a paragraph mark that only separates it from the next story +/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the +/// guard would add an empty paragraph to every header and footer and push the body down when +/// the header reaches past the top margin (#5922). +/// +/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a +/// mark stays a part (an empty header, not one linked to the previous section), and a mark right +/// after a table row stays the paragraph that closes the table. +fn header_story_blocks( + ctx: &WalkCtx<'_>, + a: u32, + b: u32, + pics: &mut media::Pictures, + max_paragraphs: usize, +) -> Result { + let mut paras = walk(ctx, a, b, pics); + if ctx.paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark); + if let [.., prev, last] = paras.as_slice() { + let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none(); + if guard && outside_table(prev) { + paras.pop(); + } + } + Ok(table::assemble(paras)) +} + /// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len` /// CPs, as absolute CPs clamped to that subdocument; `None` when empty. fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> { diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 3acc38cd1b..863801463f 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1157,6 +1157,58 @@ fn header_story_becomes_part() { assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text")))); } +/// The odd header's blocks for a one-section document whose header story is `story`, with +/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`): +/// paragraph texts, a table as ``; `None` when the slot has no part. +fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec)>) -> Option> { + let spec = StructSpec { + text: "body\r", + header_stories: vec![story], + sections: vec![(5, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries, + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let id = doc.last_section.headers.default?; + let part = doc.parts.get(&id).expect("part"); + Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "
".to_string(), |p| p.text.clone())).collect()) +} + +/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that +/// is not part of its contents. Read as a paragraph, it put an empty line under every header +/// and pushed the first body line down when the header reached past the top margin. +#[test] +fn header_story_guard_mark_is_not_a_paragraph() { + let plain = || vec![(0, 0, Vec::new())]; + // One line typed into the header: content "Header\r" + guard. + assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()])); + // Enter pressed after it: the empty paragraph is content and stays, only the guard goes. + assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()])); + // An empty header (one empty content paragraph + guard) is still a header of its own. + assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()])); + // A story that is only a mark (no guard) keeps its paragraph and its part. + assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()])); + // A story without a guard keeps its last paragraph. + assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()])); +} + +/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that +/// closes the table, not a guard to drop. +#[test] +fn header_story_mark_after_a_table_row_is_kept() { + let in_tbl = grpprl(&[(0x2416, &[0x01])]); + let ttp: Vec = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat(); + // Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8). + let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())]; + assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["
".to_string(), String::new()])); +} + /// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column, /// built per the spec (cb counts the bytes after it, plus one). fn t_def_table(edges: &[i16]) -> Vec { diff --git a/crates/docx/src/read/story/runs.rs b/crates/docx/src/read/story/runs.rs index d43cc3a214..f97b42b1c7 100644 --- a/crates/docx/src/read/story/runs.rs +++ b/crates/docx/src/read/story/runs.rs @@ -1,3 +1,5 @@ +use wordcraft_doc::math::MathJc; + use super::{ CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx, TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink, @@ -13,6 +15,7 @@ impl Reader<'_> { } } + /// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.). fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) { match k.name.as_str() { "w:r" => self.read_run(sc, pb, k, rels, ctx, depth), @@ -76,25 +79,38 @@ impl Reader<'_> { } "w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1), "m:oMath" => { - let mut props = self.run_props_none(ctx); - let mut math = crate::read::math::read_omath(k, Default::default()); - if let Some(c) = crate::read::math::lift_color(&mut math) { - props.color = Some(TextColor::Rgb(c)); - } - let linear = wordcraft_doc::math::to_linear(&math.nodes); - self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props); + let props = self.run_props_none(ctx); + self.emit_omath(sc, pb, k, Default::default(), false, &props); } "m:oMathPara" => { - let props = self.run_props_none(ctx); + let base_props = self.run_props_none(ctx); let jc = crate::read::math::para_jc(k); - for m in k.children("m:oMath") { - let mut math = crate::read::math::read_omath(m, jc); - let mut props = props.clone(); - if let Some(c) = crate::read::math::lift_color(&mut math) { - props.color = Some(TextColor::Rgb(c)); + for c in k.els() { + match c.name.as_str() { + "m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props), + "w:ins" | "w:moveTo" => { + let mut sub_ctx = ctx.clone(); + sub_ctx.ins = + Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) }); + let sub_props = self.run_props_none(&sub_ctx); + for m in c.children("m:oMath") { + self.emit_omath(sc, pb, m, jc, true, &sub_props); + } + } + "w:del" | "w:moveFrom" => { + let mut sub_ctx = ctx.clone(); + sub_ctx.del = Some(if c.name == "w:moveFrom" { + self.move_revision(false, c, false) + } else { + self.revision(RevisionKind::Delete, c) + }); + let sub_props = self.run_props_none(&sub_ctx); + for m in c.children("m:oMath") { + self.emit_omath(sc, pb, m, jc, true, &sub_props); + } + } + _ => {} } - let linear = wordcraft_doc::math::to_linear(&math.nodes); - self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props); } } "mc:AlternateContent" => { @@ -384,6 +400,17 @@ impl Reader<'_> { } } + /// Read an `m:oMath` element and emit its [`InlineObject::Equation`]. + fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) { + let mut math = crate::read::math::read_omath(m, jc); + let mut props = props.clone(); + if let Some(color) = crate::read::math::lift_color(&mut math) { + props.color = Some(TextColor::Rgb(color)); + } + let linear = wordcraft_doc::math::to_linear(&math.nodes); + self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props); + } + pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) { match sink(sc) { Some(f) => { diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 5e8bc21ba8..96924f9258 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -4,6 +4,7 @@ use super::section::comment_reference_style; use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr}; impl Writer<'_> { + /// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.). pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { match o { // Written by `para_content` (inline) or `blocks` (around paragraphs). @@ -51,10 +52,12 @@ impl Writer<'_> { Some(TextColor::Rgb(c)) => Some(c), _ => None, }; + self.rev_open(w, props); match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) { Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color), None => crate::write::math::write_equation(w, linear, *display, math, color), } + self.rev_close(w, props); } InlineObject::Field { instr, result, locked, dirty, form, .. } => { self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props) diff --git a/crates/docx/tests/it/line_numbering.rs b/crates/docx/tests/it/line_numbering.rs new file mode 100644 index 0000000000..8d2ed03e2d --- /dev/null +++ b/crates/docx/tests/it/line_numbering.rs @@ -0,0 +1,31 @@ +//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number" +//! character style that formats the numbers (#6018). + +use std::sync::Arc; + +use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering}; +use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor}; + +/// Every line-numbering option and a localised "line number" style (German Word calls its id +/// `Zeilennummer`) survive a save and reopen, beside two columns. +#[test] +fn line_numbering_options_and_style_round_trip() { + let mut d = Document::from_text("one\ntwo\nthree"); + let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous }; + d.last_section.line_numbers = Some(numbering.clone()); + d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() }; + let red = TextColor::Rgb(Rgb(0xC0, 0, 0)); + Arc::make_mut(&mut d.styles).upsert(Style { + id: "Zeilennummer".into(), + name: "Line Number".into(), + kind: StyleKind::Character, + chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() }, + ..Default::default() + }); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(back.last_section.line_numbers, Some(numbering)); + assert_eq!(back.last_section.columns.count, 2); + let st = back.styles.find("line number").expect("the line number style"); + assert_eq!(st.id, "Zeilennummer"); + assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true))); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 910106646a..cb53177d31 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -20,6 +20,7 @@ mod glossary; mod heading_levels; mod legacy_forms; mod ligatures; +mod line_numbering; mod malformed; mod missing_pictures; mod nested_block_controls; diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index d9cdc374c7..8fed477270 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -994,6 +994,80 @@ fn tracked_changes_round_trip() { assert!(r.settings.track_changes); } +#[test] +fn tracked_equations_round_trip() { + // Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math. + let mut d = Document::new(); + d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None }); + d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None }); + d.settings.track_changes = true; + let ins = CharProps { ins: Some(0), ..Default::default() }; + let del = CharProps { del: Some(1), ..Default::default() }; + let plain = CharProps::default(); + let mut p = Paragraph::with_text("Formula: ", CharProps::default()); + let end = p.len(); + p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap(); + let end = p.len(); + p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap(); + let end = p.len(); + p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap(); + let end = p.len(); + p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap(); + let end = p.len(); + p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap(); + d.body = vec![para_block(p.clone())]; + let bytes = wordcraft_docx::write(&d).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains(r#""#), "ins directly encloses inline math: {xml}"); + assert!( + xml.contains(r#""#), + "del directly encloses display math: {xml}" + ); + assert!( + xml.contains(r#""#), + "ins directly encloses display math: {xml}" + ); + assert!(xml.contains(r#""#), "del directly encloses inline math: {xml}"); + assert!(xml.contains(r#""#), "plain equation follows closing tag without revision wrapper: {xml}"); + let r = wordcraft_docx::read(&bytes).expect("read"); + assert_eq!(r.revisions, d.revisions); + let got = ¶s(&r)[0]; + assert_eq!(got.objects.len(), 5); + let eq_summary: Vec<(bool, String)> = got + .objects + .iter() + .map(|o| match o { + InlineObject::Equation { display, linear, .. } => (*display, linear.clone()), + _ => panic!("expected equation object, got {o:?}"), + }) + .collect(); + assert_eq!( + eq_summary, + vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),] + ); + assert_eq!(got.runs, p.runs); +} + +#[test] +fn nested_omath_para_revisions_read() { + // Defensive reader check: w:ins and w:del nested inside m:oMathPara. + let mut d = Document::new(); + d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None }); + d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None }); + let doc_xml = r#"x+1y-1"#; + let base = wordcraft_docx::write(&d).unwrap(); + let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes()); + let r = wordcraft_docx::read(&bytes).expect("read"); + let got = ¶s(&r)[0]; + assert_eq!(got.objects.len(), 2); + assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1")); + assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1")); + assert_eq!(got.runs[0].props.ins, Some(0)); + assert_eq!(got.runs[1].props.del, Some(1)); +} + #[test] fn tracked_paragraph_marks_round_trip() { // Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 76b11c67fc..1c8b53dc62 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -51,6 +51,11 @@ chrono = { workspace = true, features = ["clock"] } # O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666). libc = { workspace = true } +# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs). +# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate. +[target.'cfg(target_vendor = "apple")'.dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } + [dev-dependencies] wordcraft-doc = { workspace = true, features = ["test-support"] } encoding_rs = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 0a8214b612..bf71b35d63 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -347,19 +347,24 @@ pub(crate) struct InsertEffect { /// Insert typed text at the caret (replacing the selection), with pending formatting and track changes. pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> { - insert_text(s, text, true).map(|_| ()) + insert_text(s, text, true, None).map(|_| ()) } /// [`type_text`] with the effect the insertion had. pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result { - insert_text(s, text, true) + insert_text(s, text, true, None) } /// Insert pasted plain text like [`type_text`], except that its paragraph separators are text /// being transferred, not Enter key presses: an empty list item between them stays (Enter on /// one would end the list). pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> { - insert_text(s, text, false).map(|_| ()) + insert_text(s, text, false, None).map(|_| ()) +} + +/// Insert typed text in a chosen language without losing caret formatting or tracked changes. +pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> { + insert_text(s, text, true, language).map(|_| ()) } /// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders, @@ -368,7 +373,7 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> { /// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph /// right-to-left direction when its first strong letter is RTL, and returns whether content or state /// changed. -fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result { +fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result { let mut props = s.typing_props(); if s.sel.is_collapsed() { s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus)); @@ -383,6 +388,9 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result Vec { vec![ CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| { @@ -113,7 +116,7 @@ pub fn specs() -> Vec { s.doc.last_section.footers = Default::default(); sel_result(s) }), - CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#), + CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#), CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#), CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#), // Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields. diff --git a/crates/engine/src/cmd/insert/items.rs b/crates/engine/src/cmd/insert/items.rs index d703e8f220..5343437592 100644 --- a/crates/engine/src/cmd/insert/items.rs +++ b/crates/engine/src/cmd/insert/items.rs @@ -1,8 +1,72 @@ +use super::PartKind; use super::header_footer::hf_part; use super::{ Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value, - delete_selection, json, p, para_block, sel_result, type_text, + delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang, }; +use std::collections::BTreeSet; + +fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option) -> bool { + let Some(blocks) = doc.story(story) else { return false }; + let mut seen = BTreeSet::from([story]); + let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect(); + while let Some(block) = pending.pop() { + let para = match block { + Block::Para(para) => para, + Block::Table(table) => { + pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b)); + continue; + } + }; + for object in ¶.objects { + if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE"))) + { + return true; + } + for id in object.text_boxes() { + if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox) + && seen.insert(StoryRef::Part(id)) + { + pending.extend(part.blocks.iter().map(|b| &**b)); + } + } + } + } + false +} + +fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result { + let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }; + let mut para = Paragraph::new().styled(style); + para.props.align = Some(align); + para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]); + if x_of_y { + para.insert_text(0, "Page of ", &CharProps::default())?; + para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?; + let end = para.len(); + para.insert_object( + end, + InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, + &CharProps { bold: Some(true), ..Default::default() }, + )?; + } else { + para.insert_object(0, field, &CharProps::default())?; + } + Ok(para) +} + +// Only cached display text is transferable; instructions, flags and formatting remain part of ownership. +fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) { + for object in &mut to.objects { + let InlineObject::Field { instr, result, .. } = object else { continue }; + if let Some(cached) = from.objects.iter().find_map(|object| match object { + InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result), + _ => None, + }) { + result.clone_from(cached); + } + } +} pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult { let position = p::str(v, "position").unwrap_or("bottom"); @@ -22,30 +86,86 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult { } let header = position == "top"; let id = hf_part(s, header); - let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" }); - para.props.align = Some(align); - para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]); - if x_of_y { - para.insert_text(0, "Page of ", &CharProps::default())?; - para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?; - let end = para.len(); - para.insert_object( - end, - InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, - &CharProps { bold: Some(true), ..Default::default() }, - )?; - } else { - para.insert_object(0, field, &CharProps::default())?; + let story = StoryRef::Part(id); + let style = if header { "Header" } else { "Footer" }; + let mut para = page_number_paragraph(style, align, x_of_y)?; + // Replace only a top-level paragraph matching our generated structure. User text, + // field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number. + // Cached field results and paragraph-mark formatting survive gallery changes and identical requests. + if story_has_page_field(&s.doc, story, None) { + let generated = s.doc.story(story).and_then(|blocks| { + blocks.iter().enumerate().find_map(|(i, block)| { + let Block::Para(existing) = &**block else { return None }; + let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None }; + [false, true] + .into_iter() + .any(|x_of_y| { + page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| { + if p.objects.len() != existing.objects.len() { + return false; + } + p.mark = existing.mark.clone(); + copy_page_number_results(&mut p, existing); + *existing == p + }) + }) + .then_some((i, existing)) + }) + }); + // More than one PAGE makes ownership ambiguous, including in tables or anchored text + // boxes. The traversal visits each story once so cycles cannot hang insertion. + if let Some((i, existing)) = generated + && !story_has_page_field(&s.doc, story, Some(i)) + { + para.mark = existing.mark.clone(); + copy_page_number_results(&mut para, existing); + let replacement = para_block(para); + if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) { + let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?; + *block = replacement; + } + } + return Ok(json!({"story": id})); } - s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?; + let blocks = s.doc.story_mut(story)?; + // A DOCX round trip can add paragraph-mark formatting to the otherwise untouched + // Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line. + if let [block] = blocks.as_slice() + && let Block::Para(existing) = &**block + { + let mut placeholder = Paragraph::new().styled(style); + placeholder.mark = existing.mark.clone(); + if *existing == placeholder { + para.mark = existing.mark.clone(); + blocks.clear(); + } + } + blocks.push(para_block(para)); Ok(json!({"story": id})) } +/// Insert date and time at the caret or selection, formatted with a Word picture or default format. pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult { - let fmt = p::str(v, "format").unwrap_or("M/d/yyyy"); - let text = format_date(fmt); + let language = p::str(v, "language"); + if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) { + return Err(CmdError::Params("language must be en-US or pt-BR".into())); + } + let mut props = s.typing_props(); + if let Some(lang) = language { + props.lang = Some(lang.to_string()); + } + // Resolve the same run language used by refresh_date_fields on subsequent updates. + // Resolving language from the selection start ordered().0 is intentional for the fixed-text path + // so the typed text takes the character/paragraph styling where the insertion begins. + let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang); + // A missing picture follows the selected or inherited language, just like a DATE field + // without a \\@ switch; an explicitly supplied picture is never overridden. + let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref())); + if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') { + return Err(CmdError::Params("invalid date picture".into())); + } + let text = format_date_in(fmt, effective_language.as_deref()); if p::bool(v, "update").unwrap_or(false) { - let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object( &at, @@ -54,32 +174,87 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult { )?; s.sel = Selection::caret(end); } else { - type_text(s, &text)?; + type_text_with_lang(s, &text, language)?; } sel_result(s) } -/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…). +/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…), +/// in English. pub fn format_date(fmt: &str) -> String { - // Word shows the computer's local date and time (#1707), not UTC's. + format_date_in(fmt, None) +} + +/// Format today's date with a Word picture, month and weekday names in the language `lang` (a +/// tag such as `pt-BR`; English when `None` or a language without names here). +pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String { let now = crate::cmd::now_unix(); - format_date_at(fmt, now, crate::cmd::local_offset_secs(now)) + format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now)) } /// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone /// `offset_secs` east of UTC. pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String { + format_date_at_in(fmt, None, unix, offset_secs) +} + +/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone +/// `offset_secs` east of UTC, with language `lang`. +pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String { let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs); - format_date_parts(fmt, y, m as usize, d, hms, days) + format_date_parts_in(fmt, lang, y, m as usize, d, hms, days) +} + +/// Month and weekday names of a language, as Word writes them in date fields: full names, the +/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop. +/// Weekdays start on Thursday (1970-01-01). +struct DateNames { + /// Full month names (January through December). + months: [&'static str; 12], + /// Weekday names starting on Thursday (1970-01-01). + days: [&'static str; 7], + /// The picture of a `DATE` field without `\@`. + default: &'static str, +} + +const ENGLISH: DateNames = DateNames { + months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"], + default: "M/d/yyyy", +}; + +/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`, +/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture. +const PORTUGUESE: DateNames = DateNames { + months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"], + days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"], + default: "dd/MM/yyyy", +}; + +/// Choose month and weekday names according to the primary language subtag. +fn date_names(lang: Option<&str>) -> &'static DateNames { + let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or(""); + if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH } +} + +/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language. +pub fn default_date_picture(lang: Option<&str>) -> &'static str { + date_names(lang).default } /// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday. -pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String { - const MONTHS: [&str; 12] = - ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; - let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January"); - let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday"); +pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String { + format_date_parts_in(fmt, None, y, m, d, hms, days_since) +} + +/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is +/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere. +#[allow(clippy::too_many_arguments)] +pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String { + let names = date_names(lang); + let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]); + let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]); + let three = |s: &str| -> String { s.chars().take(3).collect() }; let mut out = String::new(); let chars: Vec = fmt.chars().collect(); let mut i = 0; @@ -121,11 +296,11 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32 ('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")), ('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)), ('M', 4..) => out.push_str(month), - ('M', 3) => out.push_str(month.get(..3).unwrap_or(month)), + ('M', 3) => out.push_str(&three(month)), ('M', 2) => out.push_str(&format!("{m:02}")), ('M', 1) => out.push_str(&m.to_string()), ('d' | 'D', 4..) => out.push_str(day), - ('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)), + ('d' | 'D', 3) => out.push_str(&three(day)), ('d' | 'D', 2) => out.push_str(&format!("{d:02}")), ('d' | 'D', 1) => out.push_str(&d.to_string()), ('H', 2..) => out.push_str(&format!("{hh:02}")), @@ -337,3 +512,79 @@ pub fn drop_cap_state(s: &Session) -> DropCapState { text_font, } } + +#[cfg(test)] +mod date_time_locale_tests { + use super::*; + /// A DATE field without an explicit language initially uses the paragraph's and document's + /// inherited pt-BR language, just as the subsequent refresh will. + #[test] + fn date_field_inherits_document_default_locale_on_first_render() { + let mut s = Session::new(wordcraft_doc::Document::new()); + std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into()); + s.run( + "insert.dateTime", + &json!({ + "format": "d 'de' MMMM 'de' yyyy", "update": true + }), + ) + .unwrap(); + + let path = s.doc.para_paths(StoryRef::Body).remove(0); + let p = s.doc.para(StoryRef::Body, &path).unwrap(); + let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") }; + let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")); + assert_eq!(*result, expected); + assert!(result.contains(" de ")); + let before = result.clone(); + crate::cmd::references::refresh_date_fields(&mut s.doc); + let p = s.doc.para(StoryRef::Body, &path).unwrap(); + let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") }; + assert_eq!(*result, before, "refresh must not switch languages"); + } + + /// Fixed date text uses the selected pt-BR editing language, without dropping the + /// caret's other character formatting or making the insertion refreshable. + #[test] + fn fixed_date_text_keeps_selected_language() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run( + "insert.dateTime", + &json!({ + "format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false + }), + ) + .unwrap(); + let path = s.doc.para_paths(StoryRef::Body).remove(0); + let p = s.doc.para(StoryRef::Body, &path).unwrap(); + assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR")); + assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"))); + assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. }))); + } + + /// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run. + /// The existing three-character abbreviations must preserve accented Portuguese characters. + #[test] + fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() { + let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2); + assert_eq!(format("d''MM", 20), "20'10"); + assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro"); + assert_eq!(format("ddd", 20), "sáb"); + assert_eq!(format("MMM", 20), "out"); + assert_eq!(format("MMMM", 20), "outubro"); + } + + /// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh. + #[test] + fn pt_br_date_time_dialog_inserts_dynamic_field() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap(); + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let mut doc = wordcraft_docx::read(&bytes).unwrap(); + crate::cmd::references::refresh_date_fields(&mut doc); + let paths = doc.para_paths(StoryRef::Body); + let p = doc.para(StoryRef::Body, &paths[0]).unwrap(); + let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") }; + assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"))); + } +} diff --git a/crates/engine/src/cmd/references/fields.rs b/crates/engine/src/cmd/references/fields.rs index 86d81ce49e..f56752f9e5 100644 --- a/crates/engine/src/cmd/references/fields.rs +++ b/crates/engine/src/cmd/references/fields.rs @@ -378,8 +378,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { let name = wordcraft_layout::fields::field_name(instr); match name.as_str() { "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => { - let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); - updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic)))); + let lang = field_lang(&s.doc, p, k); + let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref())); + updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref())))); } "REF" | "PAGEREF" => { let f = FieldCode::parse(instr); @@ -505,8 +506,9 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { continue; } // Word's default picture when the code carries no `\@` switch. - let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); - updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic)))); + let lang = field_lang(doc, p, k); + let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref())); + updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref())))); } if updates.is_empty() { continue; @@ -522,6 +524,16 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { } } +/// Resolve a field object's language through the paragraph and document defaults. +fn field_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph, k: usize) -> Option { + let off = p.object_offsets().get(k).copied()?; + doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(off)).lang +} + +/// Word's default date or time picture when a field instruction carries no `\@` picture switch. +fn default_picture(name: &str, lang: Option<&str>) -> String { + if name == "TIME" { "h:mm am/pm".into() } else { crate::cmd::insert::default_date_picture(lang).into() } +} /// Most names SET fields give values. const MAX_SET: usize = 10_000; diff --git a/crates/engine/src/cmd/tools/compare.rs b/crates/engine/src/cmd/tools/compare.rs index d275d73652..a62397d830 100644 --- a/crates/engine/src/cmd/tools/compare.rs +++ b/crates/engine/src/cmd/tools/compare.rs @@ -5,7 +5,7 @@ use super::{ use wordcraft_doc::para::OBJ; /// Compare and Combine take the same settings. -pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#; +pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#; /// What Compare / Combine compares (the More >> settings of Word's dialog). struct CompareOptions { @@ -27,6 +27,15 @@ impl CompareOptions { } } +/// Context and tracking counters for diffing paragraph contents. +struct DiffCtx<'a> { + ins: u32, + del: u32, + opts: &'a CompareOptions, + budget: &'a mut u64, + char_fallbacks: &'a mut usize, +} + /// Changed paragraphs longer than this (in characters) are compared word by word even at /// character level, so the comparison table stays small. const MAX_CHAR_DIFF: usize = 2000; @@ -35,6 +44,9 @@ const MAX_CHAR_DIFF: usize = 2000; /// result shows the differences as tracked insertions, deletions and formatting changes, labelled /// with `author`. Options Word offers that the comparison can't honour yet (moves, comments, /// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result. +/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds +/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the +/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`. pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { let revised = if let Some(t) = p::str(v, "text") { Document::from_text(t) @@ -61,7 +73,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { formatting: p::bool(v, "formatting").unwrap_or(true), characters: matches!(level, "character" | "char"), }; - let not_honoured: Vec<&str> = + let mut not_honoured: Vec<&str> = ["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect(); let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone()); let date = crate::cmd::now_iso(); @@ -83,7 +95,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))), }; let mut budget = MAX_COMPARE_CELLS; + let mut char_fallbacks = 0usize; let ops = lcs_ops(&old.iter().map(key).collect::>(), &new.iter().map(key).collect::>(), &mut budget); + let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks }; let mut out: Vec> = Vec::new(); let (mut i, mut j) = (0usize, 0usize); let mut changes = 0usize; @@ -97,7 +111,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { if !same_objects(&s.doc, pa, pb) { // Same text, but objects (text boxes, pictures…) differ: diffed, so the // original's objects aren't lost. - out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))); + out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))); changes += 1; i += 1; j += 1; @@ -129,9 +143,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { for x in 0..dels.max(inss) { let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None }); match (a.map(|x| &**x), b.map(|x| &**x)) { - (Some(Block::Para(pa)), Some(Block::Para(pb))) => { - out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))) - } + (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))), _ => { if let Some(x) = a { out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0))); @@ -162,8 +174,21 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { // Compare creates an untitled result from the current document. s.clear_versions(); } + if char_fallbacks > 0 { + log::warn!( + "compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff" + ); + if !not_honoured.contains(&"level") { + not_honoured.push("level"); + } + } s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); - Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured})) + Ok(json!({ + "changes": changes, + "author": author, + "notHonoured": not_honoured, + "characterFallback": char_fallbacks, + })) } /// A block's text (a table's cells in order), for matching blocks. @@ -177,13 +202,14 @@ fn block_text(b: &Block, depth: usize) -> String { } } -/// A copy of the block with every run (in tables, every cell's) changed by `f`. +/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`. fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block { match b { Block::Para(p) => { let mut p = p.clone(); let len = p.len(); let _ = p.format(0, len, f); + f(&mut p.mark); Block::Para(p) } Block::Table(t) => { @@ -205,59 +231,96 @@ pub(super) enum Op { Ins, } -/// Longest sequence the comparison table is built for; the rest of a longer sequence is -/// compared as deleted and inserted, never dropped. +/// Longest sequence window per LCS comparison table chunk; sequences longer than this are +/// aligned in successive chunked windows rather than truncated, preserving tail alignment. const MAX_LCS: usize = 4000; /// Comparison table cells one compare may fill over all its tables (blocks and every changed /// paragraph pair); once spent, the remaining middles are compared as deleted and inserted. const MAX_COMPARE_CELLS: u64 = 50_000_000; -/// LCS edit script between two sequences: the common start and end are matched first, then the -/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the -/// middle is deleted and inserted whole. +/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`) +/// between sequences `a` and `b`. +/// +/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`], +/// charging comparison cells against `budget`. Between windows, intermediate common prefixes +/// are trimmed to re-anchor alignment without table allocations. When the remaining cells +/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions. pub(super) fn lcs_ops(a: &[T], b: &[T], budget: &mut u64) -> Vec { let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count(); let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default()); let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count(); - let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default()); + let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default(); + let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default(); let mut ops = vec![Op::Same; pre]; - let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS)); - let cells = (n as u64).saturating_mul(m as u64); - if cells > *budget { - (n, m) = (0, 0); - } else { - *budget -= cells; - } - let mut t = vec![vec![0u32; m + 1]; n + 1]; - for i in (0..n).rev() { - for j in (0..m).rev() { - let v = if a.get(i) == b.get(j) { - t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1 - } else { - t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0)) - }; - if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) { - *c = v; + + while !rem_a.is_empty() && !rem_b.is_empty() { + let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count(); + if p > 0 { + ops.extend(std::iter::repeat_n(Op::Same, p)); + rem_a = rem_a.get(p..).unwrap_or_default(); + rem_b = rem_b.get(p..).unwrap_or_default(); + if rem_a.is_empty() || rem_b.is_empty() { + break; } } - } - let (mut i, mut j) = (0, 0); - while i < n && j < m { - if a.get(i) == b.get(j) { - ops.push(Op::Same); - i += 1; - j += 1; - } else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) { - ops.push(Op::Del); - i += 1; - } else { - ops.push(Op::Ins); - j += 1; + + let n = rem_a.len().min(MAX_LCS); + let m = rem_b.len().min(MAX_LCS); + let cells = (n as u64).saturating_mul(m as u64); + if cells > *budget || n == 0 || m == 0 { + break; } + *budget = budget.saturating_sub(cells); + + let stride = m.saturating_add(1); + let table_len = n.saturating_add(1).saturating_mul(stride); + let mut t = vec![0u32; table_len]; + + for i in (0..n).rev() { + let row = i.saturating_mul(stride); + let next_row = i.saturating_add(1).saturating_mul(stride); + for j in (0..m).rev() { + let v = if rem_a.get(i) == rem_b.get(j) { + t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1) + } else { + let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0); + let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0); + down.max(right) + }; + if let Some(c) = t.get_mut(row.saturating_add(j)) { + *c = v; + } + } + } + + let (mut i, mut j) = (0usize, 0usize); + while i < n && j < m { + let row = i.saturating_mul(stride); + let next_row = i.saturating_add(1).saturating_mul(stride); + if rem_a.get(i) == rem_b.get(j) { + ops.push(Op::Same); + i = i.saturating_add(1); + j = j.saturating_add(1); + } else { + let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0); + let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0); + if down >= right { + ops.push(Op::Del); + i = i.saturating_add(1); + } else { + ops.push(Op::Ins); + j = j.saturating_add(1); + } + } + } + + rem_a = rem_a.get(i..).unwrap_or_default(); + rem_b = rem_b.get(j..).unwrap_or_default(); } - ops.extend(std::iter::repeat_n(Op::Del, a.len() - i)); - ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j)); + + ops.extend(std::iter::repeat_n(Op::Del, rem_a.len())); + ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len())); ops.extend(std::iter::repeat_n(Op::Same, suf)); ops } @@ -410,15 +473,39 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) { } } -fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { - let (at, bt) = (a.plain_text(), b.plain_text()); - let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF; +/// Returns `true` if paragraph `p` contains more characters than `limit`. +/// +/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects +/// are present, avoiding O(n) traversal of large paragraphs. +fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool { + if p.objects.is_empty() { + p.text.chars().take(limit.saturating_add(1)).count() > limit + } else { + p.plain_text().chars().take(limit.saturating_add(1)).count() > limit + } +} + +/// Diffs two matched paragraphs either at word granularity or at character granularity according +/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds +/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks` +/// so callers can discover the fallback. +fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph { + let chars = if ctx.opts.characters { + if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) { + *ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1); + false + } else { + true + } + } else { + false + }; let (ta, tb) = (tokens(a, chars), tokens(b, chars)); let key = |t: &Tok<'_>| match t { - Tok::Text(t) => Key::Text(opts.key(t)), + Tok::Text(t) => Key::Text(ctx.opts.key(t)), Tok::Obj(o) => obj_key(doc, o), }; - let ops = lcs_ops(&ta.iter().map(key).collect::>(), &tb.iter().map(key).collect::>(), budget); + let ops = lcs_ops(&ta.iter().map(key).collect::>(), &tb.iter().map(key).collect::>(), ctx.budget); // Only at character granularity: a matched whole word almost never happens by coincidence, // but a matched single character very often does (digits, punctuation), so only there does // a short interior match need folding back into its surrounding edit. @@ -436,11 +523,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, o j += 1; } Op::Del => { - put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() }); + put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() }); i += 1; } Op::Ins => { - put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() }); + put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() }); j += 1; } } diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 3d2de2d844..0dcc04e0fc 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -66,6 +66,309 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() { assert_eq!(collapse_noise_matches(&ops), ops); } +/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows, +/// replaying operations to prove exact reconstruction and asserting specific edit indices. +#[test] +fn lcs_ops_chunks_across_multiple_windows() { + // Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows). + // Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix. + let total = 9000; + let mut a: Vec = (0..total).collect(); + let mut b = a.clone(); + a[0] = 99999; + b[0] = 88888; + a[total - 1] = 77777; + b[total - 1] = 66666; + let moved = a[50]; + b.remove(50); + b.insert(8500, moved); + + let mut budget = u64::MAX; + let ops = lcs_ops(&a, &b, &mut budget); + + // Replay ops against `a` and `b` to verify exact sequence reconstruction and order. + let mut ia = 0; + let mut ib = 0; + let mut dels_seen = Vec::new(); + let mut inss_seen = Vec::new(); + let mut reconstructed = Vec::new(); + + for op in &ops { + match op { + Op::Same => { + assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}"); + if let Some(&val) = a.get(ia) { + reconstructed.push(val); + } + ia += 1; + ib += 1; + } + Op::Del => { + if let Some(&val) = a.get(ia) { + dels_seen.push((ia, val)); + } + ia += 1; + } + Op::Ins => { + if let Some(&val) = b.get(ib) { + inss_seen.push((ib, val)); + reconstructed.push(val); + } + ib += 1; + } + } + } + + assert_eq!(ia, a.len()); + assert_eq!(ib, b.len()); + assert_eq!(reconstructed, b); + // Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500. + assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]); + assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]); +} + +/// Verifies that `lcs_ops` forces successive full 4,000-element windows without +/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion. +#[test] +fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() { + // Force successive full 4,000-element windows without intermediate prefix trimming + // collapsing the middle into fewer tables. + // Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000). + // Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0] + // at the beginning of each window, forcing intermediate prefix trim to find p == 0 so + // all three full DP tables must execute. + let total = 9000; + let mut a: Vec = (0..total).collect(); + let mut b = a.clone(); + a[0] = 11111; + b[0] = 22222; + a[4000] = 33333; + b[4000] = 44444; + a[8000] = 55555; + b[8000] = 66666; + a[total - 1] = 77777; + b[total - 1] = 88888; + + // Budget for 3 full windows: + // Window 1: 4,000 * 4,000 = 16,000,000 cells + // Window 2: 4,000 * 4,000 = 16,000,000 cells + // Window 3: 1,000 * 1,000 = 1,000,000 cells + // Total cells = 33,000,000. + let mut budget = 33_000_000u64; + let ops = lcs_ops(&a, &b, &mut budget); + + // Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built. + assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing"); + + let mut ia = 0; + let mut ib = 0; + let mut reconstructed = Vec::new(); + let mut dels_seen = Vec::new(); + let mut inss_seen = Vec::new(); + + for op in &ops { + match op { + Op::Same => { + assert_eq!(a.get(ia), b.get(ib)); + if let Some(&val) = a.get(ia) { + reconstructed.push(val); + } + ia += 1; + ib += 1; + } + Op::Del => { + if let Some(&val) = a.get(ia) { + dels_seen.push((ia, val)); + } + ia += 1; + } + Op::Ins => { + if let Some(&val) = b.get(ib) { + inss_seen.push((ib, val)); + reconstructed.push(val); + } + ib += 1; + } + } + } + + assert_eq!(ia, a.len()); + assert_eq!(ib, b.len()); + assert_eq!(reconstructed, b); + assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]); + assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]); + + // Also verify completely disjoint sequences across multiple windows with zero common items. + // Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff) + // Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff) + // Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells + // Total cells = 36,000,000. + let a_diff: Vec = (0..9000).collect(); + let b_diff: Vec = (10_000..19_000).collect(); + let mut budget_diff = 36_000_000u64; + let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff); + assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget"); + assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000); + assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000); + assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0); +} + +/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation, +/// one-short exhaustion, and multi-window exhaustion after the first chunk. +#[test] +fn lcs_ops_exhausted_budget_falls_back_whole() { + // 1. Budget 0 before any table: middle degrades whole, budget remains 0 + let a = vec![1, 2, 3, 4, 5]; + let b = vec![1, 8, 9, 5]; + let mut budget = 0; + let ops = lcs_ops(&a, &b, &mut budget); + assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]); + assert_eq!(budget, 0, "budget must remain 0"); + + // 2. Exact-fit: cells == budget + // Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells. + let mut budget_exact = 6; + let ops_exact = lcs_ops(&a, &b, &mut budget_exact); + assert_eq!(budget_exact, 0, "budget exactly exhausted to 0"); + assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]); + + // Exact-fit with shared elements in middle + let a_mid = vec![1, 2, 3, 5]; + let b_mid = vec![1, 2, 9, 5]; + // Middle is (3) vs (9): 1 * 1 = 1 cell + let mut budget_mid = 1; + let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid); + assert_eq!(budget_mid, 0); + assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]); + + // 3. One-short: budget == cells - 1 + let mut budget_short = 5; + let ops_short = lcs_ops(&a, &b, &mut budget_short); + assert_eq!(budget_short, 5, "one-short budget must not be charged"); + assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]); + + // 4. Multi-window: Exhaustion after the first window + // Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells). + let mut a_multi: Vec = (0..4500).collect(); + let mut b_multi = a_multi.clone(); + a_multi[0] = 99999; + b_multi[0] = 88888; + a_multi[4000] = 77777; + b_multi[4000] = 66666; + a_multi[4499] = 55555; + b_multi[4499] = 44444; + + // Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2 + let mut budget_w1_only = 16_000_000u64; + let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only); + assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2"); + // Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss + let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count(); + let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count(); + assert_eq!(tail_dels, 500); + assert_eq!(tail_inss, 500); + + // Case 4B: budget fits both windows exactly: 16,250,000 + let mut budget_both = 16_250_000u64; + let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both); + assert_eq!(budget_both, 0, "both windows charged exactly to 0"); + assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3); + assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3); + + // Case 4C: one short on window 2: 16_249_999 + let mut budget_both_short = 16_249_999u64; + let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short); + assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent"); +} + +/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`, +/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths. +#[test] +fn lcs_ops_boundary_cases_and_break_paths() { + // 1. Empty sequences + let mut b = u64::MAX; + assert_eq!(lcs_ops::(&[], &[], &mut b), Vec::::new()); + + // 2. One-sided empty + let items = [1, 2, 3]; + assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]); + assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]); + + // 3. Exact MAX_LCS (4,000) + let len_max = 4000; + let mut a_max: Vec = (0..len_max).collect(); + let mut b_max = a_max.clone(); + a_max[0] = 99999; + b_max[0] = 88888; + a_max[len_max - 1] = 77777; + b_max[len_max - 1] = 66666; + let mut b_budget = u64::MAX; + let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget); + assert_eq!(ops_max.len(), len_max + 2); + assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2); + + // 4. Exact MAX_LCS + 1 (4,001) + let len_plus1 = 4001; + let mut a_p1: Vec = (0..len_plus1).collect(); + let mut b_p1 = a_p1.clone(); + a_p1[0] = 99999; + b_p1[0] = 88888; + a_p1[len_plus1 - 1] = 77777; + b_p1[len_plus1 - 1] = 66666; + let mut b_budget2 = u64::MAX; + let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2); + assert_eq!(ops_p1.len(), len_plus1 + 2); + assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2); + + // 5. Exact 2 * MAX_LCS (8,000) + let len_2m = 8000; + let mut a_2m: Vec = (0..len_2m).collect(); + let mut b_2m = a_2m.clone(); + a_2m[0] = 11111; + b_2m[0] = 22222; + a_2m[4000] = 33333; + b_2m[4000] = 44444; + a_2m[len_2m - 1] = 55555; + b_2m[len_2m - 1] = 66666; + let mut b_budget3 = u64::MAX; + let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3); + assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3); + assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3); + assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3); + + // 6. Prefix-trim break paths inside the while loop: + // Case 6A: rem_a becomes empty after matching p elements + let mut a_break_a: Vec = (0..4005).collect(); + let mut b_break_a: Vec = (0..4008).collect(); + a_break_a[0] = 99999; + b_break_a[0] = 88888; + let mut b_break1 = u64::MAX; + let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1); + assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1); + assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4); + + // Case 6B: rem_b becomes empty after matching p elements + let mut a_break_b: Vec = (0..4008).collect(); + let mut b_break_b: Vec = (0..4005).collect(); + a_break_b[0] = 99999; + b_break_b[0] = 88888; + let mut b_break2 = u64::MAX; + let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2); + assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4); + assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1); + + // Case 6C: both become empty simultaneously after matching p elements + let mut a_break_both: Vec = (0..4005).collect(); + let mut b_break_both: Vec = (0..4005).collect(); + a_break_both[0] = 99999; + b_break_both[0] = 88888; + let mut b_break3 = u64::MAX; + let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3); + assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1); + assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1); + assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004); +} + #[test] fn compare_at_word_level_still_matches_a_whole_unchanged_word() { // `collapse_noise_matches` only runs for character-level compares (wired up in @@ -80,6 +383,145 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() { ); } +#[test] +fn compare_character_level_surfaces_fallback_on_long_paragraphs() { + // Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally: + // sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level". + let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10); + let revised_short = original_short.replacen("jumps", "jumped", 1); + assert!(original_short.chars().count() < 2000); + let mut s = Session::new(Document::from_text(&original_short)); + let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(0)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs"); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs"); + assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs"); + + // Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff. + // The fallback is surfaced in result metadata: characterFallback is incremented and + // notHonoured reports "level" rather than silently degrading without signal. + // In the output document, the fallback marks the whole word rather than sub-word fragments. + let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50); + let revised_long = original_long.replacen("jumps", "jumped", 1); + assert!(original_long.chars().count() > 2000); + let mut s = Session::new(Document::from_text(&original_long)); + let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(1)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs"); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word"); + assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word"); +} + +#[test] +fn compare_character_level_exact_boundaries_at_limit() { + let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars + + // Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback. + let original_2000 = format!("{prefix}1234567890123456789x"); + let revised_2000 = format!("{prefix}1234567890123456789y"); + assert_eq!(original_2000.chars().count(), 2000); + assert_eq!(revised_2000.chars().count(), 2000); + let mut s = Session::new(Document::from_text(&original_2000)); + let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(0)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back"); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars"); + assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars"); + + // Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff. + let original_2001 = format!("{prefix}12345678901234567890x"); + let revised_2001 = format!("{prefix}12345678901234567890y"); + assert_eq!(original_2001.chars().count(), 2001); + assert_eq!(revised_2001.chars().count(), 2001); + let mut s = Session::new(Document::from_text(&original_2001)); + let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(1)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback"); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars"); + assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars"); +} + +#[test] +fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() { + let short_a = "Short original text here."; + let short_b = "Short revised text here."; + let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50); + let long_b = long_a.replacen("jumps", "jumped", 1); + let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40); + let long_d = long_c.replacen("Another", "Different", 1); + + // Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1. + let doc_mixed_a = format!("{short_a}\n{long_a}"); + let doc_mixed_b = format!("{short_b}\n{long_b}"); + let mut s = Session::new(Document::from_text(&doc_mixed_a)); + let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(1)); + + // Document with 2 long modified paragraphs: fallback count is 2. + let doc_2long_a = format!("{long_a}\n{long_c}"); + let doc_2long_b = format!("{long_b}\n{long_d}"); + let mut s = Session::new(Document::from_text(&doc_2long_a)); + let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(2)); + + // Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1. + let mut s = Session::new(Document::from_text(short_a)); + let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(1)); + + // Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1. + let mut s = Session::new(Document::from_text(&long_a)); + let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(1)); +} + +#[test] +fn compare_word_level_default_and_explicit_never_flag_fallback() { + let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50); + let revised_long = original_long.replacen("jumps", "jumped", 1); + assert!(original_long.chars().count() > 2000); + + // Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured. + let mut s = Session::new(Document::from_text(&original_long)); + let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(0)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(!not_honoured.iter().any(|v| v == "level")); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "jumps "); + assert_eq!(inserted, "jumped "); + + // Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured. + let mut s = Session::new(Document::from_text(&original_long)); + let r = s.run("review.compare", &json!({"text": revised_long})).unwrap(); + assert_eq!(r["characterFallback"].as_u64(), Some(0)); + let not_honoured = r["notHonoured"].as_array().expect("notHonoured array"); + assert!(!not_honoured.iter().any(|v| v == "level")); + let p = s.doc.body[0].as_para().expect("body paragraph"); + let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); + assert_eq!(deleted, "jumps "); + assert_eq!(inserted, "jumped "); +} + #[test] fn compare_keeps_text_boxes_of_the_original() { // Text boxes are anchored in body paragraphs: one after changed text, one alone in an @@ -193,6 +635,124 @@ fn compare_bounds_total_diff_work() { assert_eq!(s.doc.plain_text(StoryRef::Body), revised); } +/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false +/// revisions in the tail, asserting exact changed block indices, text, and edit types. +#[test] +fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() { + // Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0 + // and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000). + // Without chunking/continuation past the window, the tail misaligns and positional pairing + // marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599, + // deletion at 100, insertion at 4400). + let total = 4600; + let mut orig_paras: Vec = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect(); + let mut rev_paras = orig_paras.clone(); + // Zero out prefix trim: substitution at 0 + orig_paras[0] = "Record 0 has alpha beta gamma original.".into(); + rev_paras[0] = "Record 0 has alpha beta gamma revised.".into(); + // Zero out suffix trim: substitution at 4599 + orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1); + rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1); + // Move record 100 to 4400 + let moved = orig_paras[100].clone(); + rev_paras.remove(100); + rev_paras.insert(4400, moved); + + let original = orig_paras.join("\n"); + let revised = rev_paras.join("\n"); + + let mut s = Session::new(Document::from_text(&original)); + let r = s.run("review.compare", &json!({"text": revised})).unwrap(); + + // The true number of changed paragraphs is 4 (the two substitutions, the move's deletion, + // and the move's insertion). On unpatched code, window truncation misaligned the tail + // and marked ~202 paragraphs. + assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail"); + + // In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600): + let changed_blocks: Vec<(usize, String)> = s + .doc + .body + .iter() + .enumerate() + .filter_map(|(idx, b)| { + let p = b.as_para()?; + let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some()); + if has_change { Some((idx, p.plain_text())) } else { None } + }) + .collect(); + + assert_eq!(changed_blocks.len(), 4); + assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into())); + assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into())); + assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into())); + assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into())); + + // Verify exact change types on those blocks: + let b0 = s.doc.body[0].as_para().unwrap(); + assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement"); + let b100 = s.doc.body[100].as_para().unwrap(); + assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole"); + let b4401 = s.doc.body[4401].as_para().unwrap(); + assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole"); + let b4600 = s.doc.body[4600].as_para().unwrap(); + assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement"); + + s.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), revised); +} + +/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions, +/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`. +#[test] +fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() { + // Whole block delete/insert: test with differing paragraph count so whole-block paths trigger + let orig_multi = "Keep start.\nDelete me.\nKeep end."; + let rev_multi = "Keep start.\nKeep end."; + let mut s2 = Session::new(Document::from_text(orig_multi)); + if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) { + p.mark.bold = Some(true); + } + s2.run("review.compare", &json!({"text": rev_multi})).unwrap(); + + // In s2.doc.body, block 1 was deleted whole. + // Verify that p.mark.del is recorded by mark_block and formatting is preserved. + let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph"); + assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark"); + assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block"); + + // Accept all changes: deleted paragraph is removed + s2.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi); + + // Test whole-block insertion + let mut s3 = Session::new(Document::from_text(rev_multi)); + s3.run("review.compare", &json!({"text": orig_multi})).unwrap(); + let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph"); + assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark"); + s3.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi); + + // Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision) + let orig_empty = "Before.\n\nAfter."; + let rev_empty = "Before.\nAfter."; + let mut s4 = Session::new(Document::from_text(orig_empty)); + s4.run("review.compare", &json!({"text": rev_empty})).unwrap(); + let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block"); + assert_eq!(empty_del.len(), 0); + assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del"); + s4.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty); + + let mut s5 = Session::new(Document::from_text(rev_empty)); + s5.run("review.compare", &json!({"text": orig_empty})).unwrap(); + let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block"); + assert_eq!(empty_ins.len(), 0); + assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins"); + s5.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty); +} + #[test] fn macros_record_and_run() { let mut s = Session::new(Document::new()); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0c2c490ba1..c17adf2be9 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -8,12 +8,14 @@ use wordcraft_layout::{NumeralMode, ViewMode}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; +/// Toggles or sets a boolean view property, returning the updated state. fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult { let cur = *f(&mut s.view); *f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur); Ok(json!({"value": *f(&mut s.view)})) } +/// Returns the command specifications for the View ribbon tab and window management commands. pub fn specs() -> Vec { vec![ CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| { @@ -143,7 +145,7 @@ pub fn specs() -> Vec { }) .pure(), CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows) - .params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#) + .params(r#"{"id"?: n (make this open document the active one), "window"?: n (make this open window the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#) .pure(), CommandSpec::new("view.split", "Split", "View › Window", |s, _| { s.push_ui_request(json!({"toggle": "split"})); @@ -213,6 +215,8 @@ pub fn specs() -> Vec { pub struct Windows { /// The host can list and move its windows (the desktop app). pub available: bool, + /// The active window, when known. + pub current: Option, /// The other open windows, by id. pub others: Vec, /// View Side by Side is on, with this window. @@ -431,6 +435,7 @@ pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) { s.view.single_column = fit == "onePage" || fit == "pageWidth"; } +/// Sets the view zoom level or fit mode, or requests opening the Zoom dialog. fn zoom(s: &mut Session, v: &Value) -> CmdResult { match v.get("value") { Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { @@ -456,7 +461,13 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult { /// View › Window › Switch Windows: the open documents, or make one of them active. fn switch_windows(s: &mut Session, v: &Value) -> CmdResult { - if let Some(x) = v.get("id").filter(|x| !x.is_null()) { + if let Some(w) = v.get("window").filter(|x| !x.is_null()) { + let wid = w.as_u64().ok_or_else(|| CmdError::Params("`window` must be an open window's id".into()))?; + if s.windows.available && s.windows.current != Some(wid) && !s.windows.others.iter().any(|other| other.id == wid) { + return Err(CmdError::Params(format!("no open window {wid}"))); + } + s.push_ui_request(json!({"windows": "switchWindow", "window": wid})); + } else if let Some(x) = v.get("id").filter(|x| !x.is_null()) { let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?; s.activate(id)?; } diff --git a/crates/engine/src/io/safe_save.rs b/crates/engine/src/io/safe_save.rs index 525e7a3dc9..e5c3b161b3 100644 --- a/crates/engine/src/io/safe_save.rs +++ b/crates/engine/src/io/safe_save.rs @@ -11,6 +11,9 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a +/// temporary file. If the destination's folder refuses the temporary file, a new file +/// is created directly and removed on failure; an existing file is left untouched. pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { let permissions = match fs::metadata(path) { Ok(meta) if meta.is_file() => Some(meta.permissions()), @@ -28,11 +31,41 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { } // Synchronize the prepared file before replacing the destination. Directory durability // and network-filesystem/power-loss guarantees are outside this operation's contract. - file.sync_all() + sync_file(file) }); written.map_err(explain) } +/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush +/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems +/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`, +/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but +/// does not guarantee disk cache durability; any other error is returned as it is. +fn sync_file(file: &File) -> io::Result<()> { + #[cfg(target_vendor = "apple")] + let plain = || rustix::fs::fsync(file).map_err(io::Error::from); + #[cfg(not(target_vendor = "apple"))] + let plain = || Ok(()); + or_plain_fsync(file.sync_all(), plain) +} + +/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush. +fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + match full { + Err(e) if full_flush_unsupported(&e) => fsync(), + other => other, + } +} + +/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB), +/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. +fn full_flush_unsupported(e: &io::Error) -> bool { + const EINVAL: i32 = 22; + const ENOTTY: i32 = 25; + const ENOTSUP: i32 = 45; + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY)) +} + /// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS /// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003). /// There is no mode to keep there, so the save goes on; any other failure still stops it. @@ -448,4 +481,41 @@ mod tests { assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); assert_eq!(fs::read(&path).unwrap(), b"Saved again."); } + + /// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync + /// on Apple platforms; every other sync error still fails the save. + #[test] + fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { + use std::cell::Cell; + let refused = |errno| Err(io::Error::from_raw_os_error(errno)); + for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] { + let fsynced = Cell::new(false); + let r = or_plain_fsync(refused(errno), || { + fsynced.set(true); + Ok(()) + }); + let apple = cfg!(target_vendor = "apple"); + assert_eq!(r.is_ok(), apple, "{name}"); + assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); + } + // The plain fsync's own failure is reported. + if cfg!(target_vendor = "apple") { + let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); + } + // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. + for errno in [5, 28, 13] { + let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); + } + assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); + } + + #[test] + fn sync_file_flushes_a_local_file() { + let d = Scratch::new(); + let path = d.0.join("f.bin"); + let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + sync_file(&file).unwrap(); + } } diff --git a/crates/engine/src/tests/documents.rs b/crates/engine/src/tests/documents.rs index 7dd108b893..cdeeff5135 100644 --- a/crates/engine/src/tests/documents.rs +++ b/crates/engine/src/tests/documents.rs @@ -214,6 +214,7 @@ fn read_aloud_belongs_to_its_document() { assert_eq!(s.read_aloud.document, Some(b)); } +/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID. #[test] fn switch_windows_lists_and_activates() { let mut s = s(); @@ -228,10 +229,23 @@ fn switch_windows_lists_and_activates() { assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); run(&mut s, "view.switchWindows", json!({"id": a})); assert_eq!(s.document_id(), a); + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 1})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]); + s.windows.available = true; + s.windows.current = Some(1); + s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }]; + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 2})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]); + assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available"); for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); assert_eq!(s.document_id(), a); } + for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + } let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; for id in ["view.switchWindows", "file.closeDocument", "file.new"] { for j in &junk { diff --git a/crates/engine/tests/it/date_fields.rs b/crates/engine/tests/it/date_fields.rs index cda2009492..e826ed9627 100644 --- a/crates/engine/tests/it/date_fields.rs +++ b/crates/engine/tests/it/date_fields.rs @@ -186,3 +186,36 @@ fn binary_doc_opened_by_extension_refreshes_date_fields() { assert_eq!(got, expected, "{name}: DATE field stale after opening"); } } + +/// Regression from a synthetic Word document: a visible Brazilian long date is +/// stored in a TIME field and pt-BR is present only in docDefaults, not in field runs. +#[test] +fn a_word_time_field_inherits_pt_br_from_document_defaults() { + const PICTURE: &str = "d' de 'MMMM' de 'yyyy"; + const CODE: &str = r#"TIME \@ "d' de 'MMMM' de 'yyyy""#; + let rels = r#""#; + let doc_rels = r#""#; + let styles = r#""#; + let doc = format!( + r#"Cidade, {}Cidade, {}"#, + field(CODE, "22 de setembro de 2026", false), + field(CODE, "22 de setembro de 2026", false) + ); + let source = zip(&[("_rels/.rels", rels), ("word/document.xml", &doc), ("word/_rels/document.xml.rels", doc_rels), ("word/styles.xml", styles)]); + let original = wordcraft_engine::io::open_bytes("pt-br-word.docx", &source).unwrap(); + let expected = wordcraft_engine::cmd::insert::format_date_in(PICTURE, Some("pt-BR")); + let assert_correct = |document: &wordcraft_doc::Document| { + let got = fields(document); + assert_eq!(got.len(), 2, "both original fields survive"); + for (code, result) in got { + assert_eq!(code, CODE, "field instruction remains editable"); + assert_eq!(result, expected, "quoted Portuguese words and month names"); + assert!(!result.contains("October") && !result.contains('\'')); + assert_eq!(result.matches(" de ").count(), 2); + } + }; + assert_correct(&original); + let written = wordcraft_docx::write(&original).unwrap(); + let reopened = wordcraft_engine::io::open_bytes("reopened.docx", &written).unwrap(); + assert_correct(&reopened); +} diff --git a/crates/engine/tests/page_number_preservation.rs b/crates/engine/tests/page_number_preservation.rs new file mode 100644 index 0000000000..de6bd01b1e --- /dev/null +++ b/crates/engine/tests/page_number_preservation.rs @@ -0,0 +1,398 @@ +//! #2201: inserting page numbers must preserve the header/footer story. +use std::sync::Arc; + +use serde_json::json; +use wordcraft_doc::para::{GroupChild, InlineObject}; +use wordcraft_doc::props::{Align, CharProps}; +use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block}; +use wordcraft_engine::{Selection, Session}; + +fn fields(doc: &Document, story: StoryRef, name: &str) -> usize { + doc.para_paths(story) + .iter() + .filter_map(|path| doc.para(story, path)) + .flat_map(|p| &p.objects) + .filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name)))) + .count() +} + +fn field(instr: &str) -> InlineObject { + InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() } +} + +#[test] +fn numbering_preserves_content_formatting_selection_and_undo_redo() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + for format in ["plain", "x of y"] { + let mut s = Session::new(Document::from_text("Body text")); + let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right); + let mut table = Table::new(1, 2, 300.0); + table.rows[0].cells[0].blocks = + vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))]; + s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table))); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let blocks = s.doc.story(story).unwrap().clone(); + let depth = s.undo_depth(); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice()); + assert_eq!(s.sel, selection); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); + assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y")); + assert_eq!(s.undo_depth(), depth + 1); + let after = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + assert_eq!(s.sel, selection); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content"); + let mut back = after.clone(); + for _ in 0..2 { + back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap(); + let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap(); + let reopened = StoryRef::Part(id); + assert_eq!(back.plain_text(StoryRef::Body), "Body text"); + assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table")); + assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right)); + let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") }; + let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") }; + assert_eq!(p.props_at(0).bold, Some(true)); + assert_eq!(fields(&back, reopened, "PAGE"), 1); + assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y")); + } + } + } +} + +#[test] +fn existing_page_field_in_a_table_is_preserved_with_its_switches() { + for depth in [1, 17] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32; + let mut para = Paragraph::with_text("Existing page: ", CharProps::default()); + para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap(); + let mut blocks = vec![para_block(para)]; + for _ in 0..depth { + let mut table = Table::new(1, 1, 300.0); + table.rows[0].cells[0].blocks = blocks; + blocks = vec![Arc::new(Block::Table(table))]; + } + s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks); + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap(); + assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated"); + } +} + +#[test] +fn existing_range_page_field_is_not_duplicated() { + let mut s = Session::new(Document::new()); + let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let para = s.doc.para_mut(story, &Path::top(0)).unwrap(); + para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap(); + para.insert_text(para.len(), "7", &CharProps::default()).unwrap(); + para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap(); + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": "top"})).unwrap(); + assert_eq!(s.doc, before); +} + +#[test] +fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() { + let mut s = Session::new(Document::new()); + let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let para = s.doc.para_mut(story, &Path::top(0)).unwrap(); + para.props.align = Some(Align::Right); + let empty = para.clone(); + let mut para = Paragraph::new(); + para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap(); + s.doc.story_mut(story).unwrap().push(para_block(para)); + s.run("insert.pageNumber", &json!({"position": "top"})).unwrap(); + assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty); + assert_eq!(fields(&s.doc, story, "PAGEREF"), 1); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); +} + +#[test] +fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() { + for position in ["top", "bottom"] { + let mut s = Session::new(Document::new()); + let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1); + } + let mut s = Session::new(Document::from_text("Before selected after")); + s.run("select.text", &json!({"text": "selected"})).unwrap(); + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": "current"})).unwrap(); + assert!(s.doc.parts.is_empty()); + assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1); + assert!(!s.doc.plain_text(StoryRef::Body).contains("selected")); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); +} + +#[test] +fn existing_page_field_in_nested_text_boxes_is_preserved() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + for levels in [1, 2] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32; + if levels == 2 { + // Imported stories can contain a nested box, even though the insert command + // does not offer one while the caret is already in a text box. + let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]); + let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone(); + if let InlineObject::Shape { story, .. } = &mut anchor { + *story = Some(inner); + } + s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap(); + s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner))); + } + s.run("insert.pageNumber", &json!({"position": "current"})).unwrap(); + let before = s.doc.clone(); + let selection = s.sel.clone(); + s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap(); + assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number"); + assert_eq!(s.doc, before); + assert_eq!(s.sel, selection); + let mut back = s.doc.clone(); + for _ in 0..2 { + back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap(); + assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::(), 1); + assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::(), 0); + } + } + } +} + +#[test] +fn existing_page_field_in_grouped_text_boxes_is_preserved() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("insert.pageNumber", &json!({"position": "current"})).unwrap(); + let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap(); + *anchor = InlineObject::Group { + w: 144.0, + h: 72.0, + float: Default::default(), + ch_w: 144.0, + ch_h: 72.0, + children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }], + canvas: None, + }; + let before = s.doc.clone(); + let selection = s.sel.clone(); + s.run("insert.pageNumber", &json!({"position": position})).unwrap(); + assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number"); + assert_eq!(s.doc, before); + assert_eq!(s.sel, selection); + let mut back = s.doc.clone(); + for _ in 0..2 { + back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap(); + assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::(), 1); + } + } +} + +#[test] +fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() { + let mut s = Session::new(Document::from_text("Body")); + let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("insert.pageNumber", &json!({"position": "current"})).unwrap(); + let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap(); + assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1); + assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1); +} + +#[test] +fn cyclic_text_box_references_do_not_block_numbering() { + let mut s = Session::new(Document::from_text("Body")); + let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32; + let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32; + let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone(); + s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap(); + s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap(); + assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1); +} + +#[test] +fn generated_numbers_switch_format_without_losing_neighboring_content() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let mut table = Table::new(1, 1, 300.0); + table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))]; + s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table))); + let blocks = s.doc.story(story).unwrap().clone(); + let selection = s.sel.clone(); + for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] { + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap(); + assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice()); + assert_eq!(s.sel, selection); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); + assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y")); + let expected = match align { + "left" => Align::Left, + "right" => Align::Right, + _ => Align::Center, + }; + assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected)); + let after = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + } + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap(); + assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table")); + assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1); + assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0); + } +} + +#[test] +fn repeated_numbering_keeps_clean_state_and_undo_depth() { + for position in ["top", "bottom"] { + for format in ["plain", "x of y"] { + let mut s = Session::new(Document::new()); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + s.dirty = false; + let doc = s.doc.clone(); + let depth = s.undo_depth(); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + assert_eq!(s.doc, doc); + assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step"); + assert!(!s.dirty, "an identical request must not dirty the document"); + } + } +} + +#[test] +fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32; + s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() }; + let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap(); + let story = StoryRef::Part(id); + let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone(); + assert_eq!(mark.bold, Some(true)); + let mut s = Session::new(doc); + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": position})).unwrap(); + assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line"); + assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); + let inserted = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap(); + assert_eq!(s.doc.story(story).unwrap().len(), 1); + assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark); + assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right)); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); + assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, inserted); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + } +} + +#[test] +fn user_owned_page_number_lookalikes_are_preserved() { + for kind in ["formatting", "metadata", "another field"] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + match kind { + "formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0), + "metadata" => { + let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else { + panic!("no page field") + }; + *locked = true; + } + _ => { + let mut para = Paragraph::with_text("User page: ", CharProps::default()); + para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap(); + s.doc.story_mut(story).unwrap().push(para_block(para)); + } + } + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap(); + assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated"); + } +} + +#[test] +fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() { + for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] { + for format in ["plain", "x of y"] { + let mut s = Session::new(Document::from_text("Body")); + let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects { + if let InlineObject::Field { instr, result, .. } = object { + *result = if instr == "PAGE" { "7" } else { "12" }.into(); + } + } + let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap(); + let story = StoryRef::Part(id); + let mut s = Session::new(doc); + let cached = |s: &Session, name: &str| { + s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object { + InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()), + _ => None, + }) + }; + assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen"); + assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12")); + let kept = s.doc.story(story).unwrap()[0].clone(); + let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone(); + let selection = s.sel.clone(); + s.dirty = false; + let before = s.doc.clone(); + let depth = s.undo_depth(); + s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap(); + assert_eq!(s.doc, before, "identical requests must retain refreshed results"); + assert_eq!(s.undo_depth(), depth); + assert!(!s.dirty); + for (next_format, align) in [("x of y", "right"), ("plain", "left")] { + let before = s.doc.clone(); + s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap(); + assert_eq!(s.doc.story(story).unwrap()[0], kept); + assert_eq!(s.sel, selection); + assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark); + assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left })); + assert_eq!(fields(&s.doc, story, "PAGE"), 1); + assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y")); + assert_eq!(cached(&s, "PAGE").as_deref(), Some("7")); + if format == "x of y" && next_format == "x of y" { + assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12")); + } + let after = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + } + } + } +} diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 92f1b5a6da..4306e0f4d2 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -1177,7 +1177,8 @@ impl Styles { "graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a), props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a), "text-properties" if in_default && cur.is_none() => { - self.default_font = default_font(&a, &self.faces); + // A later default style without a (usable) font keeps the earlier one (#4501). + self.default_font = default_font(&a, &self.faces).or(self.default_font.take()); if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) { self.default_size = Some(sz.min(1638.0)); } diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index 233e47ea6a..79431d4a8d 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -1289,6 +1289,36 @@ fn meta_creation_date_timezones_and_fallbacks() { assert_eq!(m2.author, "Solo Creator"); } +#[test] +fn a_later_default_style_without_a_font_keeps_the_default_font() { + // #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font. + let mut styles = styles::Styles::default(); + let with_font = format!( + r#""# + ); + let without = format!( + r#""# + ); + styles.read(with_font.as_bytes(), false); + styles.read(without.as_bytes(), false); + assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif")); +} + +#[test] +fn a_later_monospaced_default_style_keeps_the_proportional_default_font() { + // #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one. + let mut styles = styles::Styles::default(); + let with_font = format!( + r#""# + ); + let mono = format!( + r#""# + ); + styles.read(with_font.as_bytes(), false); + styles.read(mono.as_bytes(), false); + assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif")); +} + /// The `content.xml` of a package. pub(super) fn content_of(bytes: &[u8]) -> String { let mut z = archive(bytes); diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index 4e3d1b4f66..399fa44091 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -7,8 +7,10 @@ use super::{ ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox, }; -/// The character style Word formats line numbers with (`w:styleId="LineNumber"`). +/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English +/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name. const LINE_NUMBER_STYLE: &str = "LineNumber"; +const LINE_NUMBER_STYLE_NAME: &str = "line number"; /// A layout pass: finished, or to be done again with the page totals it found, or with the /// floats placed above their anchors it found ([`EarlyFloats`]). @@ -199,7 +201,11 @@ impl Ctx<'_> { // Word formats line numbers with the "Line Number" character style over the document's // defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are // Aptos 12 pt), not in a fixed small grey. - let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone()); + let styles = &self.doc.styles; + let style = styles + .get(LINE_NUMBER_STYLE) + .or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character)) + .map(|s| s.id.clone()); let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() }); p.props.space_after = Some(0.0); p.props.space_before = Some(0.0); diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 233add4ef0..f47cb16151 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -777,7 +777,8 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec, notes: &[(usize, } } -/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`. +/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in: +/// each column of a multi-column section gets its own (#6018), as in Word. fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec { let mut items = Vec::new(); if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers) @@ -800,7 +801,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): para: num.clone(), l0: 0, l1: 1, - x: pb.sect.margin_left + pb.sect.gutter - dist - w, + x: pb.col_x() - dist - w, y: ly, turn: TextDirection::Horizontal, }); diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index fa09783f3d..a29b2a700c 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -270,12 +270,21 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), Tops { body, first, foot, first_foot } } -/// Set up the walk state for a section: its grid, field values and footnote separators. -fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) { - let (doc, sect, si) = (ctx.doc, sec.sect, sec.si); +/// Set up the walk state for a section before its header and footer room is measured: no grid yet +/// (the previous section's must not leak into headers, footers and separators), the field values +/// and the footnote separators. +fn begin_section_state<'p>( + ctx: &mut Ctx, + pb: &mut PageBuilder<'p>, + (si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat), + totals: &Totals, + web: bool, +) { + let doc = ctx.doc; + ctx.grid = para::Grid::default(); pb.sect = sect; - pb.page_format = sec.page_format; - ctx.fields.page_format = sec.page_format; + pb.page_format = page_format; + ctx.fields.page_format = page_format; pb.sect_idx = si; pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText; if !web && !doc.footnote_separators.is_empty() { @@ -283,7 +292,11 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p } ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1); ctx.fields.section_pages = totals.section(si); - ctx.grid = para::Grid::of(doc, sect); +} + +/// The section's own grid and line numbering, once its header and footer room is known. +fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) { + ctx.grid = para::Grid::of(ctx.doc, sect); if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) { pb.line_no = 0; } @@ -541,17 +554,22 @@ pub(crate) fn layout_pass( _ if mode.reflow => sect_ref, _ => sect, }; - ctx.fields.page_format = page_format; - let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned()); let start = if si == 0 { SectionStart::NextPage } else { sect.start }; - let sec = Sec { si, sect, page_format, start, tops }; if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) { // The page the section before ends on is finished with its own margins and footnote // separators, before this section's replace them: the footnotes pending on it // belong to the section whose page it is (#5289). pb.finish_page(); } - begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web); + // This section's fields and separators, without the previous section's grid, before its + // header and footer room is measured; its own grid once that room is known (#4501). + begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web); + let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned()); + // Measuring the room took the (reflow) section's own number format; the body keeps the + // document section's. + ctx.fields.page_format = page_format; + apply_section_grid(&mut ctx, &mut pb, sect); + let sec = Sec { si, sect, page_format, start, tops }; if mode.web && si > 0 { // one long page } else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 98a75854ad..b83e5bf3fb 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4055,6 +4055,76 @@ fn line_numbers_follow_the_line_number_style() { assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}"); } +/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take +/// its formatting, found by the built-in name. +#[test] +fn line_numbers_follow_a_localised_line_number_style() { + let mut d = Document::from_text("one\ntwo\nthree"); + d.last_section.line_numbers = Some(Default::default()); + let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0)); + Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style { + id: "Zeilennummer".into(), + name: "Line Number".into(), + kind: wordcraft_doc::StyleKind::Character, + chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() }, + ..Default::default() + }); + let l = lay(&d); + let runs: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)), + _ => None, + }) + .collect(); + assert_eq!(runs.len(), 3); + assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}"); +} + +/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the +/// second column's used to be drawn in the page margin over the first column's. +#[test] +fn line_numbers_stand_beside_each_column() { + crate::test_fonts(); + let text = (1..=120).map(|i| format!("line {i}")).collect::>().join("\n"); + let mut d = Document::from_text(&text); + d.last_section.line_numbers = Some(Default::default()); + d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() }; + let s = d.last_section.clone(); + let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0; + let l = lay(&d); + let p = &l.pages[0]; + // Right edge and top of every number. + let numbers: Vec<(f32, f32)> = p + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => { + Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y)) + } + _ => None, + }) + .collect(); + let body_xs: Vec = p + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x), + _ => None, + }) + .collect(); + assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}"); + let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count(); + let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect(); + assert_eq!(right.len(), in_col2, "one number per second-column line"); + // Numbers end 18 pt (auto distance) left of their column. + assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}"); + let left = numbers.len() - right.len(); + assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5)); + assert!(left > 0); +} + #[test] fn line_numbers_borders_text_boxes() { crate::test_fonts(); diff --git a/crates/layout/src/tests/pages.rs b/crates/layout/src/tests/pages.rs index e55c269240..46252bac6e 100644 --- a/crates/layout/src/tests/pages.rs +++ b/crates/layout/src/tests/pages.rs @@ -627,6 +627,61 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() { } } +#[test] +fn a_sections_header_is_measured_without_the_previous_sections_grid() { + // #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header + // room is measured before section 2's grid is on, so section 1's pitch never reaches it. + let body_top = |grid: Option| { + let mut d = Document::from_text("First section\nSecond section"); + let id = d.add_part(PartKind::Header, paras("Header line", 8)); + d.last_section.headers.default = Some(id); + let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() }; + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first)); + let l = lay(&d); + assert_eq!(l.pages.len(), 2); + l.pages[1].body.y + }; + let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 }; + let plain = body_top(None); + assert!(plain > 72.0, "the header reaches past the top margin: {plain}"); + assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header"); +} + +#[test] +fn a_sections_header_is_measured_with_its_own_section_page_count() { + // #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three"). + // Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it. + // (The total in the body makes layout run again with the page totals found.) + let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22))); + for i in 1..d.body.len() { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1); + } + let field = InlineObject::Field { + instr: r"SECTIONPAGES \* CardText".into(), + result: String::new(), + locked: false, + dirty: true, + code: false, + form: String::new(), + }; + let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap(); + let mut header = Paragraph::new(); + for _ in 0..60 { + header.insert_object(header.len(), field.clone(), &Default::default()).unwrap(); + } + let id = d.add_part(PartKind::Header, vec![para_block(header)]); + d.last_section.headers.default = Some(id); + let first = SectionProps { headers: Default::default(), ..d.last_section.clone() }; + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first)); + let l = lay(&d); + assert_eq!(l.pages.len(), 24); + let p = &l.pages[1]; + let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap(); + assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}"); + assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y); +} + /// A body PAGE field shows the page number in the section's number format, as Word does (a /// cardinal-text section shows "one"), and `\*` overrides it. #[test] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f211004feb..d2163e509e 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -321,7 +321,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color()); let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption)); - let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0)); + let flows = crate::rtl::flows(ui); + let r_min_x = if flows { tile.max.x - 150.0 } else { tile.min.x }; + let r = Rect::from_min_size(pos2(r_min_x, tile.min.y), vec2(150.0, 194.0)); ui.painter().rect( r, 2.0, @@ -352,7 +354,8 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { if let Some(h) = tex { ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } - ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color()); + let x = if flows { r.max.x - galley.size().x } else { r.min.x }; + ui.painter().galley(pos2(x, r.max.y), galley, ui.visuals().text_color()); if resp.clicked() { let _ = app.run("file.new", json!({"template": template})); app.ui.backstage = false; diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 3003642b9b..14592fb9f9 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -1156,3 +1156,139 @@ fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() { assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}"); assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}"); } + +/// Recursively collects bounding rectangles and text entries from egui shapes. +fn collect_shapes(shape: &egui::Shape, rects: &mut Vec, texts: &mut Vec<(String, egui::Pos2, f32)>) { + match shape { + egui::Shape::Rect(r) => rects.push(r.rect), + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.pos, t.galley.size().x)), + egui::Shape::Vec(v) => { + for s in v { + collect_shapes(s, rects, texts); + } + } + _ => {} + } +} + +/// #6151: in an RTL interface (Arabic), template titles on the Start/Home page +/// and New page are right-aligned to their preview thumbnails rather than left-aligned. +#[test] +fn rtl_template_titles_are_right_aligned() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.language = "ar".into(); + a.ui.backstage = true; + a.ui.backstage_page = "home".into(); + let ctx = egui::Context::default(); + let mut last_shapes = Vec::new(); + for frame in 0..4 { + let input = egui::RawInput { + time: Some(f64::from(frame) * 0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + a.logic(&ctx.clone()); + a.ui(ui); + }); + out.textures_delta.clear(); + if frame == 3 { + last_shapes = out.shapes; + } + } + + let mut rects = Vec::new(); + let mut texts = Vec::new(); + for s in &last_shapes { + collect_shapes(&s.shape, &mut rects, &mut texts); + } + + let blank_caption = crate::i18n::tc("template", "Blank document"); + let (_, pos, width) = texts.iter().find(|(t, _, _)| t == blank_caption).expect("blank template caption drawn"); + let thumbnail = rects + .iter() + .find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - pos.y).abs() < 1.0) + .expect("blank template thumbnail rect"); + + let text_right = pos.x + width; + assert!( + (thumbnail.max.x - text_right).abs() < 1.0, + "template caption should be right-aligned with thumbnail in RTL: thumbnail.max.x={}, text_right={}, text_left={}, caption={}", + thumbnail.max.x, + text_right, + pos.x, + blank_caption, + ); + + // Verify the New page through the normal UI path in Arabic as well (#6151). + a.ui.backstage_page = "new".into(); + let mut new_out = ctx.run_ui( + egui::RawInput { + time: Some(0.4), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }, + |ui| { + a.logic(&ctx.clone()); + a.ui(ui); + }, + ); + new_out.textures_delta.clear(); + let mut new_rects = Vec::new(); + let mut new_texts = Vec::new(); + for s in &new_out.shapes { + collect_shapes(&s.shape, &mut new_rects, &mut new_texts); + } + for (label, _) in super::TEMPLATES { + let caption = crate::i18n::tc("template", label); + if let Some((_, new_pos, new_width)) = new_texts.iter().find(|(t, _, _)| t == caption) { + let right = new_pos.x + new_width; + let has_matching_thumbnail = new_rects.iter().any(|r| { + (r.width() - 150.0).abs() < 1.0 + && (r.height() - 194.0).abs() < 1.0 + && (r.max.y - new_pos.y).abs() < 1.0 + && (r.max.x - right).abs() < 1.0 + }); + assert!( + has_matching_thumbnail, + "New page: caption '{caption}' must be right-aligned with its 150pt thumbnail (right={right}, y={})", + new_pos.y + ); + } + } + + // Also verify that a template tile with a wide caption (>150 pt) aligns both the thumbnail + // and caption to the right edge in RTL. + let wide_label = "A very long template title that exceeds 150 points width"; + let mut wide_out = ctx.run_ui( + egui::RawInput { + time: Some(0.5), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }, + |ui| { + crate::rtl::root(ui, |ui| { + super::template_tile(ui, &mut a, wide_label, "sample"); + }); + }, + ); + wide_out.textures_delta.clear(); + let mut wide_rects = Vec::new(); + let mut wide_texts = Vec::new(); + for s in &wide_out.shapes { + collect_shapes(&s.shape, &mut wide_rects, &mut wide_texts); + } + let (_, wide_pos, wide_width) = wide_texts.iter().find(|(t, _, _)| t == wide_label).expect("wide template caption drawn"); + assert!(*wide_width > 150.0, "wide caption should exceed 150 points: {wide_width}"); + let wide_thumbnail = wide_rects + .iter() + .find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - wide_pos.y).abs() < 1.0) + .expect("wide template thumbnail rect"); + let wide_text_right = wide_pos.x + wide_width; + assert!( + (wide_thumbnail.max.x - wide_text_right).abs() < 1.0, + "wide template caption and thumbnail should share the same right edge in RTL: thumb.max.x={}, text_right={}", + wide_thumbnail.max.x, + wide_text_right, + ); +} diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 776696b429..fd7a944084 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -175,7 +175,9 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) { return; } if pressed { - handle_primary_press(app, &pointer, mods, hf_zone, hf_enter); + if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) { + return; + } } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) && let Some((from, last)) = app.canvas.column_drag @@ -229,13 +231,14 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) { show_hover_link(app, &pointer); } +/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame). fn handle_primary_press( app: &mut WordApp, pointer: &PagePointer<'_>, mods: egui::Modifiers, hf_zone: Option<(Option, bool)>, hf_enter: bool, -) { +) -> bool { let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer; let now = ui.input(|i| i.time); let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); @@ -269,14 +272,14 @@ fn handle_primary_press( } app.session.page_hint = page; } - return; + return true; } // A press in the header/footer area from the body leaves the caret where it is, as in // Word (#2103): moving it to the nearest body text scrolled the view there, away from the // footer the double-click was aiming at. if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body { app.canvas.column_drag = None; - return; + return true; } if !mods.shift && let Some((at, inner)) = layout.equation_hit(page, x, y, story) @@ -284,7 +287,7 @@ fn handle_primary_press( app.session.sel = wordcraft_engine::Selection::caret(at); let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); app.session.page_hint = page; - return; + return true; } if app.session.math.is_some() { let _ = app.run("equation.exit", json!({})); @@ -324,15 +327,15 @@ fn handle_primary_press( }; app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line }); app.canvas.dragging = true; - return; + return true; } - let Some(pos) = layout.hit(page, x, y, story) else { return }; + let Some(pos) = layout.hit(page, x, y, story) else { return true }; // Ctrl/⌘+click follows a hyperlink. if mods.command && let Some(link) = link_under(app, layout, page, x, y, &pos) { follow_link(app, link); - return; + return true; } let pj = serde_json::to_value(&pos).unwrap_or_default(); // Alt+drag selects a column (block) of text. @@ -341,7 +344,7 @@ fn handle_primary_press( app.session.page_hint = page; app.canvas.column_drag = Some(((page, x, y), (page, x, y))); app.canvas.dragging = true; - return; + return true; } app.canvas.column_drag = None; let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift})); @@ -362,6 +365,7 @@ fn handle_primary_press( app.session.page_hint = page; app.canvas.dragging = true; // Format painter applies on mouse up + false } fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62772c4b8d..a276701fcb 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2887,6 +2887,40 @@ fn right_clicking_a_link_hides_the_hover_tooltip() { assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu"); } +/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with +/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again. +#[test] +fn a_handled_press_skips_the_link_tooltip() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default()); + a.run("select.text", json!({"text": "here"})).unwrap(); + a.run("insert.link", json!({"url": "https://example.com/"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h, 6); + let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap(); + let at = screen(&mut h, c.x, c.top + c.height / 2.0); + h.event(Event::PointerMoved(at)); + steps(&mut h, 2); + let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count(); + assert!(tips(&h) > 0, "hovering the link shows its target"); + h.event(Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND }); + h.step(); + assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame"); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + steps(&mut h, 2); + assert!(tips(&h) > 0, "hovering again shows the tooltip"); +} + /// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves /// the scrollable range. #[test] diff --git a/crates/ui-egui/src/control/geometry.rs b/crates/ui-egui/src/control/geometry.rs index 0363eea447..20a97795a5 100644 --- a/crates/ui-egui/src/control/geometry.rs +++ b/crates/ui-egui/src/control/geometry.rs @@ -67,6 +67,10 @@ pub(crate) struct CaptureGeometry { pub(super) document: u64, } +/// Explanation returned when a capture is attempted on a secondary viewport. +pub(crate) const UNSUPPORTED_SECONDARY_WINDOW_ERROR: &str = + "capturing a secondary document window is not supported by the viewport renderer; ui.screenshot captures only the window in the root viewport"; + impl CaptureGeometry { /// Returns the document id the capture was admitted for. pub(crate) fn document(self) -> u64 { @@ -85,10 +89,12 @@ impl CaptureGeometry { } } - /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and - /// returns `None` when it can. + /// Explains why the surface cannot be captured: when it is in a secondary viewport that the + /// viewport renderer cannot capture, or when the window is minimized or fully occluded. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { - if self.surface.minimized { + if self.viewport != egui::ViewportId::ROOT { + Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR) + } else if self.surface.minimized { Some("the window is minimized") } else if self.surface.occluded { Some("the window is fully occluded") diff --git a/crates/ui-egui/src/control/screenshots.rs b/crates/ui-egui/src/control/screenshots.rs index b421915ed8..47044ff35a 100644 --- a/crates/ui-egui/src/control/screenshots.rs +++ b/crates/ui-egui/src/control/screenshots.rs @@ -1,4 +1,4 @@ -use super::geometry::CaptureGeometry; +use super::geometry::{CaptureGeometry, UNSUPPORTED_SECONDARY_WINDOW_ERROR}; use super::{CaptureSlot, Sender, Value, WordApp, json}; /// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless @@ -128,6 +128,19 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); return; }; + if geometry.viewport != egui::ViewportId::ROOT { + let msg = match app.root_window_info() { + Some((root_key, _doc_id, root_title)) if !root_title.is_empty() => format!( + "screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}}) for {root_title:?}." + ), + Some((root_key, ..)) => format!( + "screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}})." + ), + None => format!("screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}."), + }; + reject_shot(reply, token, msg); + return; + } if let Some(reason) = geometry.unavailable_reason() { reject_shot(reply, token, format!("screenshot failed: {reason}")); return; diff --git a/crates/ui-egui/src/dialogs/dialogs_insert.rs b/crates/ui-egui/src/dialogs/dialogs_insert.rs index 8485e85b9d..754b7d8911 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert.rs @@ -12,11 +12,16 @@ use crate::options::MeasureExt; use crate::theme::{Tokens, regular, semibold}; mod columns; +mod date_time; mod field; mod symbol; pub use columns::{ColumnsForm, DropCapForm}; use columns::{columns_ui, drop_cap_ui}; +pub use date_time::DateTimeForm; +#[cfg(test)] +pub(crate) use date_time::date_time_preview_matches; +use date_time::date_time_ui; use field::field_ui; pub use field::{DATE_FORMATS, FIELD_CATEGORIES, FIELDS, FieldForm, FieldKind, NUMBER_FORMATS, STYLEREF_SWITCHES, field_kind}; use symbol::symbol_ui; @@ -25,6 +30,8 @@ pub use symbol::{RECENT_SYMBOLS, RecentSymbol, SPECIAL, SUBSETS, SymbolForm, rem #[derive(Clone, Debug, Serialize)] #[serde(tag = "form", rename_all = "camelCase")] pub enum InsertDialog { + /// Date & Time insertion dialog. + DateTime(DateTimeForm), Columns(ColumnsForm), Symbol(SymbolForm), Field(FieldForm), @@ -35,8 +42,10 @@ pub enum InsertDialog { } impl InsertDialog { + /// The dialog name used in command dispatch and UI state. pub fn name(&self) -> &'static str { match self { + InsertDialog::DateTime(_) => "dateTime", InsertDialog::Columns(_) => "columns", InsertDialog::Symbol(_) => "symbol", InsertDialog::Field(_) => "field", @@ -51,6 +60,7 @@ impl InsertDialog { /// The window title (English; the caller translates it). pub fn title(&self) -> &'static str { match self { + InsertDialog::DateTime(_) => "Date and Time", InsertDialog::Columns(_) => "Columns", InsertDialog::Symbol(_) => "Symbol", InsertDialog::Field(_) => "Field", @@ -68,6 +78,7 @@ impl InsertDialog { /// `insertSmartArt`, or `dropCap` (Drop Cap Options…, where the paragraph can take one). pub fn open(name: &str, app: &WordApp) -> Option { Some(match name { + "dateTime" => InsertDialog::DateTime(DateTimeForm::new(app)), "columns" => InsertDialog::Columns(ColumnsForm::read(app)), "symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)), "specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)), @@ -84,6 +95,7 @@ pub fn open(name: &str, app: &WordApp) -> Option { /// Draws the dialog; returns true to close it. pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool { match d { + InsertDialog::DateTime(f) => date_time_ui(app, ui, f), InsertDialog::Columns(f) => columns_ui(app, ui, f), InsertDialog::Symbol(f) => symbol_ui(app, ui, f), InsertDialog::Field(f) => field_ui(app, ui, f), diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/date_time.rs b/crates/ui-egui/src/dialogs/dialogs_insert/date_time.rs new file mode 100644 index 0000000000..42acb422c3 --- /dev/null +++ b/crates/ui-egui/src/dialogs/dialogs_insert/date_time.rs @@ -0,0 +1,141 @@ +//! Date & Time insertion dialog (Word-style format and language selection). +use egui::{Stroke, Ui}; +use serde::Serialize; +use serde_json::{Value, json}; + +use super::buttons; +use super::field::DATE_FORMATS; +use crate::WordApp; +use crate::theme::Tokens; + +/// Predefined Portuguese (Brazil) date and time format pictures. +const PT_DATE_FORMATS: &[&str] = &[ + "dd/MM/yyyy", + "dddd, d 'de' MMMM 'de' yyyy", + "d 'de' MMMM 'de' yyyy", + "dd/MM/yy", + "yyyy-MM-dd", + "d-MMM-yy", + "dd.MM.yyyy", + "d MMMM yyyy", + "MMM-yy", + "dd/MM/yyyy HH:mm", + "dd/MM/yyyy HH:mm:ss", + "HH:mm", + "HH:mm:ss", +]; + +/// The date preview list consists of document values (not interface strings). +/// The i18n audit recognizes these dynamic values instead of treating month names as UI labels. +#[cfg(test)] +pub(crate) fn date_time_preview_matches(value: &str) -> bool { + PT_DATE_FORMATS.iter().chain(DATE_FORMATS.iter()).any(|picture| { + wordcraft_engine::cmd::insert::format_date_in(picture, Some("pt-BR")) == value + || wordcraft_engine::cmd::insert::format_date_in(picture, Some("en-US")) == value + }) +} + +/// State of the Word-like Insert > Date & Time dialog, including an optional validation error. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DateTimeForm { + /// Selected Word field picture, not a Rust strftime format. + pub format: String, + /// Language of the resulting text or DATE field. + pub language: String, + /// Whether to insert a refreshable field rather than fixed text. + pub update: bool, + /// Last insertion failure; retain the window so the user can change the choice. + #[serde(skip)] + pub error: Option, + /// Brief status confirmation message (e.g. after setting default preferences). + #[serde(skip)] + pub status: Option<&'static str>, +} +impl DateTimeForm { + /// Restore a saved format/language or choose a sensible default for the UI language. + pub fn new(app: &WordApp) -> Self { + let language = if app.ui.date_time_language.is_empty() { + if crate::i18n::current().code().to_ascii_lowercase().starts_with("pt") { "pt-BR" } else { "en-US" }.to_string() + } else if app.ui.date_time_language.to_ascii_lowercase().starts_with("pt") { + "pt-BR".to_string() + } else { + "en-US".to_string() + }; + let list = if language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS }; + let format = if list.contains(&app.ui.date_time_format.as_str()) { app.ui.date_time_format.clone() } else { list[0].to_string() }; + Self { format, language, update: app.ui.date_time_update, error: None, status: None } + } + /// Pictures available in the chosen language. + fn formats(&self) -> &'static [&'static str] { + if self.language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS } + } + /// Engine command parameters corresponding to the current controls. + pub fn params(&self) -> Value { + json!({"format": self.format, "language": self.language, "update": self.update}) + } +} +/// Draw the interactive format list, language picker and update checkbox; close on successful +/// insertion or Cancel, keeping errors visible rather than silently dismissing the dialog. +pub(super) fn date_time_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DateTimeForm) -> bool { + let tokens = Tokens::get(ui.ctx()); + ui.set_width(520.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(350.0); + ui.label(tl!("Date formats:")); + egui::Frame::new().stroke(Stroke::new(1.0, tokens.border)).inner_margin(2.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("date_time_formats").max_height(245.0).auto_shrink([false, false]).show(ui, |ui| { + for picture in f.formats() { + let sample = wordcraft_engine::cmd::insert::format_date_in(picture, Some(&f.language)); + if ui.selectable_label(f.format == *picture, sample).on_hover_text(*picture).clicked() { + f.format = (*picture).to_string(); + } + } + }); + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label(tl!("Language")); + craft_ui::ComboBox::from_id_salt("date_time_lang") + .selected_text(if f.language == "pt-BR" { tl!("Portuguese (Brazil)") } else { tl!("English (United States)") }) + .show_ui(ui, |ui| { + if ui.selectable_label(f.language == "pt-BR", tl!("Portuguese (Brazil)")).clicked() { + f.language = "pt-BR".into(); + f.format = PT_DATE_FORMATS[0].into(); + } + if ui.selectable_label(f.language == "en-US", tl!("English (United States)")).clicked() { + f.language = "en-US".into(); + f.format = DATE_FORMATS[0].into(); + } + }); + }); + }); + ui.add_space(8.0); + ui.checkbox(&mut f.update, tl!("Update automatically")); + ui.add_space(6.0); + if ui.button(tl!("Set as Default")).clicked() { + app.ui.date_time_format = f.format.clone(); + app.ui.date_time_language = f.language.clone(); + app.ui.date_time_update = f.update; + f.status = Some(tl!("Default format saved.")); + } + if let Some(status) = f.status { + ui.colored_label(tokens.text_dim, status); + } + if let Some(error) = &f.error { + ui.colored_label(ui.visuals().error_fg_color, error); + } + let (ok, cancel) = buttons(ui, "OK", "Cancel", true); + if ok { + match app.run("insert.dateTime", f.params()) { + Ok(_) => return true, + Err(error) => { + f.error = Some(error.to_string()); + return false; + } + } + } + cancel +} diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/tests.rs b/crates/ui-egui/src/dialogs/dialogs_insert/tests.rs index e000ef7e36..dbf82530ea 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/tests.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/tests.rs @@ -166,3 +166,155 @@ fn field_dialog_lists_the_document_information_fields() { f.select("SUBJECT"); assert_eq!(f.instruction(), "SUBJECT"); } + +/// The Date & Time dialog uses the saved pt-BR editing language when present in UI preferences. +#[test] +fn date_time_dialog_uses_saved_pt_br_language() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.ui.date_time_language = "pt-BR".into(); + let InsertDialog::DateTime(mut f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") }; + assert_eq!(f.format, "dd/MM/yyyy"); + assert_eq!(f.params()["language"], "pt-BR"); + assert!(!f.update); + f.update = false; + assert_eq!(f.params()["update"], false); +} + +/// Language tags with primary subtag `pt` (such as `pt_BR` or `pt-PT`) are normalized to `pt-BR`. +#[test] +fn date_time_dialog_normalizes_portuguese_language_tags() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.ui.date_time_language = "pt_BR".into(); + let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") }; + assert_eq!(f.language, "pt-BR"); + assert_eq!(f.format, "dd/MM/yyyy"); + + a.ui.date_time_language = "pt-PT".into(); + let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") }; + assert_eq!(f.language, "pt-BR"); +} + +/// Click the real egui language menu, date picture, checkbox and OK; ensure the resulting +/// document contains pt-BR fixed text with the language on its run, not just JSON params. +#[test] +fn date_time_dialog_controls_insert_portuguese_fixed_text() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.ui.language = "en".into(); + app.ui.date_time_language = "en-US".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap(); + h.run_steps(4); + + // Go through the actual language combo box and then select a long Portuguese date. + // Scope the click to this dialog: the ribbon has another English-language button. + // The popup choice has a plain accessible label; the main ribbon uses a different control. + h.get_by_role_and_label(Role::Window, "Date and Time").get_by_role(Role::ComboBox).click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Portuguese (Brazil)").click(); + h.run_steps(3); + let sample = wordcraft_engine::cmd::insert::format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")); + h.get_by_label(&sample).click(); + h.run_steps(2); + // By default, update is unchecked (false). Toggle it on and off to verify the checkbox. + assert!(matches!( + h.state().dialog.as_ref(), + Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if !f.update) + )); + h.get_by_label("Update automatically").click(); + h.run_steps(2); + assert!(matches!( + h.state().dialog.as_ref(), + Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update) + )); + h.get_by_label("Update automatically").click(); + h.run_steps(2); + assert!(matches!( + h.state().dialog.as_ref(), + Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.language == "pt-BR" && f.format == "d 'de' MMMM 'de' yyyy" && !f.update) + )); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "successful insertion closes the dialog"); + let doc = &h.state().session.doc; + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), sample); + let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph"); + assert_eq!(para.props_of_char(0).lang.as_deref(), Some("pt-BR")); + assert!(para.objects.iter().all(|o| !matches!(o, wordcraft_doc::para::InlineObject::Field { .. }))); +} + +/// Checking 'Update automatically' inserts a refreshable dynamic DATE field. +#[test] +fn date_time_dialog_controls_insert_dynamic_field() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.ui.language = "en".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap(); + h.run_steps(4); + + h.get_by_label("Update automatically").click(); + h.run_steps(2); + assert!(matches!( + h.state().dialog.as_ref(), + Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update) + )); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "successful insertion closes the dialog"); + let doc = &h.state().session.doc; + let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph"); + assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { .. }))); +} + +/// An invalid saved picture is reported in the modal, not silently dismissed when OK is clicked. +#[test] +fn date_time_dialog_keeps_open_and_displays_invalid_picture_error() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.ui.language = "en".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap(); + if let Some(crate::dialogs::Dialog::Insert(d)) = h.state_mut().dialog.as_mut() + && let InsertDialog::DateTime(f) = &mut **d + { + f.format = "invalid\\picture".into(); + } else { + panic!("date/time dialog expected"); + } + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(3); + assert!(matches!( + h.state().dialog.as_ref(), + Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.error.as_deref().is_some_and(|e| e.contains("invalid date picture"))) + )); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), ""); +} diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index b0afe864af..8bac196a70 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -235,6 +235,7 @@ impl<'a> Exempt<'a> { || matches!(plain, "WordCraft" | "WordCraft User" | "Discord" | "https://" | "auto" | "I. II. III." | "i. ii. iii." | "I, II, III, …" | "i, ii, iii, …") || plain == "CONFIDENTIAL" // the watermark dialog's text: document content, kept as typed || plain == "Figure 1" // the caption dialog's sample: the label goes into the document as is + || crate::dialogs_insert::date_time_preview_matches(plain) } } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 47b4c6b4b2..1ebd6679a7 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -254,7 +254,7 @@ fn controls(tab: &str) -> Vec { m("WordArt", "insert.wordArt"), m("Drop Cap", "insert.dropCap"), c("Signature Line", "insert.signatureLine"), - c("Date & Time", "insert.dateTime"), + d("Date & Time", "dateTime"), c("Object", "insert.object"), c("Equation", "insert.equation"), m("Symbol", "insert.symbol"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d2e37b7c8..1b324542fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,6 +294,12 @@ pub struct UiState { pub recent: Vec, /// Symbols inserted from the Symbol dialog, most recent first (#321). pub recent_symbols: Vec, + /// Saved choices in Insert > Date & Time: format picture. + pub date_time_format: String, + /// Saved language tag in Insert > Date & Time. + pub date_time_language: String, + /// Saved preference for updating inserted date/time fields automatically (defaults to false). + pub date_time_update: bool, /// Colours picked in the Colors dialog (More Colors…), most recent first (#606). pub recent_colors: Vec, /// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first: @@ -445,6 +451,7 @@ pub const MAX_RECENT_FONTS: usize = 10; pub const MAX_FONT_NAME_BYTES: usize = 128; impl Default for UiState { + /// Default user interface preferences matching Word defaults. fn default() -> Self { UiState { tab: DEFAULT_TAB.into(), @@ -455,6 +462,9 @@ impl Default for UiState { quick_access_toolbar: true, recent: Vec::new(), recent_symbols: Vec::new(), + date_time_format: String::new(), + date_time_language: String::new(), + date_time_update: false, recent_colors: Vec::new(), recent_fonts: Vec::new(), theme: theme::Appearance::default(), diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index ddbe198072..c8f9cd5c9d 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -67,11 +67,16 @@ fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture { ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)")) } +/// Whether the renderer can decode `bytes` within its budget (a 16-bit picture takes more than its pixel count says). +fn probe_fits(bytes: &[u8]) -> bool { + wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) +} + /// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from /// its header when a paste can't take its size, before anything decodes it (#3623). pub fn encoded_picture(bytes: Vec) -> ClipboardPicture { match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) { - Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes), + Ok((w, h)) if pastes(w, h) && probe_fits(&bytes) => ClipboardPicture::Encoded(bytes), Ok((w, h)) => too_large_to_paste(w, h), Err(e) => ClipboardPicture::Unreadable(e.to_string()), } @@ -258,6 +263,22 @@ mod tests { image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large"))); assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_))); + // 8000 × 6000 passes the pixel limits, but 16-bit RGBA needs 12 bytes a pixel to decode. + let mut big = Vec::new(); + image::DynamicImage::ImageRgba16(image::ImageBuffer::new(1, 1)) + .write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png) + .unwrap(); + big[16..20].copy_from_slice(&8000u32.to_be_bytes()); + big[20..24].copy_from_slice(&6000u32.to_be_bytes()); + let mut crc = 0xFFFF_FFFFu32; + for &b in &big[12..29] { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 }; + } + } + big[29..33].copy_from_slice(&(!crc).to_be_bytes()); + assert!(matches!(encoded_picture(big), ClipboardPicture::Unreadable(e) if e.contains("too large"))); // BGRA in memory: a half-transparent red pixel and an opaque blue one. let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else { panic!("pixels") diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 66eccdd0ec..ebdcd26cde 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -70,7 +70,10 @@ use shape::shape_format; use table::{table_design, table_layout}; #[cfg(test)] use tabs::MIN_TAB_W; -use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry}; +use tabs::{ + CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline, + tab_content_entry, tab_entry, +}; pub use tabs::{ StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected, in_table_public, plan_strip, resolve_tab, @@ -277,8 +280,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } let active = app.ui.tab == tab && !app.ui.backstage; resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown)); - if resp.hovered() && !active && ghost.is_none() { - ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none()); + if hover > 0.0 { + ui.painter().rect_filled( + r.shrink2(vec2(0.0, 4.0)), + 4.0, + egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover), + ); } // Entering and departing contextual tabs move and fade without changing layout. let entry = match ghost { @@ -316,7 +324,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp }; if active { let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); - ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry)); + slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate); } if resp.clicked() { let _ = app.run("ui.tab", json!({"tab": tab})); @@ -337,8 +345,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .collect(); if !left_out.is_empty() { let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered()); + if hover > 0.0 { + ui.painter().rect_filled( + r.shrink2(vec2(0.0, 4.0)), + 4.0, + egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover), + ); } // The active tab is in the menu: the button says so. let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab); @@ -376,6 +389,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)), ) .show(ui, |ui| { + // Bring newly selected ribbon content in from the side of its tab. + let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0); + let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate); + let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0); + let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout())); + shifted.set_opacity(0.2 + 0.8 * settled); + let ui = &mut shifted; // Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so // the buttons drawn over it keep their own clicks. // No document open (#1129): every command is greyed out but Help's. diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index f20556f514..bd383f7627 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -44,6 +44,7 @@ pub(super) fn shape_gallery(ui: &mut Ui, app: &mut WordApp) { }); } +/// Render the Insert ribbon tab controls. pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { group(ui, "Pages", None, app, |ui, app| { stack(ui, |ui| { @@ -189,7 +190,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { }); stack(ui, |ui| { small(ui, app, "signature", None, "Signature Line", crate::widgets::ButtonAction::new("insert.signatureLine", json!({}), false)); - small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("insert.dateTime", json!({}), false)); + small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("ui.dialog", json!({"name": "dateTime"}), false)); small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false)); }); }); diff --git a/crates/ui-egui/src/ribbon/tabs.rs b/crates/ui-egui/src/ribbon/tabs.rs index 86f954a4e2..acb04d8a3d 100644 --- a/crates/ui-egui/src/ribbon/tabs.rs +++ b/crates/ui-egui/src/ribbon/tabs.rs @@ -158,6 +158,78 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 { 1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3) } +const UNDERLINE_SLIDE_SECS: f64 = 0.2; +const CONTENT_SWITCH_SECS: f64 = 0.16; +pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0; + +#[derive(Clone, Debug, PartialEq)] +struct Underline { + tab: String, + from: (f32, f32), + to: (f32, f32), + start: f64, +} + +fn underline_at(u: &Underline, now: f64) -> (f32, f32) { + let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32); + (egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e)) +} + +pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) { + let id = egui::Id::unique("tab_underline"); + let prev: Option = ui.ctx().data(|d| d.get_temp(id)); + let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now }; + let next = match prev { + Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap }, + Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p }, + _ => snap, + }; + let (x, width) = underline_at(&next, now); + if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS { + ui.ctx().request_repaint(); + } + ui.ctx().data_mut(|d| d.insert_temp(id, next)); + ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color); +} + +#[derive(Clone, Debug, PartialEq)] +struct TabChange { + tab: String, + index: usize, + start: f64, + dir: f32, +} + +pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) { + let id = egui::Id::unique("ribbon_tab_change"); + let prev: Option = ctx.data(|d| d.get_temp(id)); + let next = match prev { + Some(p) if p.tab != tab => TabChange { + tab: tab.to_owned(), + index, + start: now, + dir: if !animate || p.index == index { + 0.0 + } else if index > p.index { + 1.0 + } else { + -1.0 + }, + }, + Some(p) => p, + None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 }, + }; + let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32; + let dir = next.dir; + ctx.data_mut(|d| d.insert_temp(id, next)); + if p < 1.0 && dir != 0.0 { + ctx.request_repaint(); + (ease_out_cubic(p), dir) + } else { + (1.0, 0.0) + } +} + /// Track contextual tabs between frames and return those still animating out. pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> { let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving")); @@ -346,4 +418,40 @@ mod transition_tests { note_contextual_tabs(&ctx, &both[..1], 7.0, true); assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again"); } + + #[test] + fn tab_underline_slides_and_can_reverse_mid_motion() { + let ctx = egui::Context::default(); + let draw = |tab: &str, x: f32, now: f64| { + ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + slide_underline( + ui, + tab, + egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)), + egui::Color32::WHITE, + now, + true, + ); + }) + .drop_without_applying_deltas(); + }; + draw("Home", 40.0, 1.0); + draw("Insert", 140.0, 2.0); + let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| { + slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true); + }); + assert!(!frame.shapes.is_empty()); + } + + #[test] + fn tab_content_motion_comes_from_the_new_tab_side() { + let ctx = egui::Context::default(); + assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0)); + let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true); + assert_eq!((p, dir), (0.0, 1.0)); + let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true); + assert!(p < 1.0); + assert_eq!(dir, -1.0); + assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0)); + } } diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index 6a5bf890fb..237f981cfa 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -35,12 +35,19 @@ pub fn enabled(app: &WordApp, id: &str) -> bool { } pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { - let fill = if resp.is_pointer_button_down_on() { + bg_if(ui, r, resp, checked, t, true); +} + +/// Paints a button background with an optional hover/press response. Disabled ribbon commands +/// pass `false` so their visual state cannot imply that they are interactive. +pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) { + let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on); + let fill = if resp.is_pointer_button_down_on() && on { t.pressed } else if checked { t.checked - } else if resp.hovered() { - t.hover + } else if hover > 0.0 { + Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover) } else { return; }; @@ -105,7 +112,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); keytip_badge(ui, r, id, &label.replace('\n', " ")); let on = enabled(app, id); - bg(ui, r, &resp, checked, &t); + bg_if(ui, r, &resp, checked, &t, on); let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); @@ -146,7 +153,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click()); keytip_badge(ui, r, id, tip); let on = enabled(app, id); - bg(ui, r, &resp, checked, &t); + bg_if(ui, r, &resp, checked, &t, on); let rtl = crate::rtl::flows(ui); let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; @@ -181,8 +188,8 @@ pub fn split( keytip_badge(ui, r, id, tip); let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click()); let on = enabled(app, id); - bg(ui, r, &resp, checked, &t); - bg(ui, ar, &aresp, false, &t); + bg_if(ui, r, &resp, checked, &t, on); + bg_if(ui, ar, &aresp, false, &t, on); let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); @@ -252,7 +259,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_ let w = (galley_w + 22.0).max(44.0); let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); keytip_badge(ui, r, "menu", tip); - bg(ui, r, &resp, false, &t); + bg_if(ui, r, &resp, false, &t, on); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a); let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); let mut y = r.min.y + BIG_LABEL_Y; @@ -265,7 +272,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_ let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0); let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click()); keytip_badge(ui, r, "menu", tip); - bg(ui, r, &resp, false, &t); + bg_if(ui, r, &resp, false, &t, on); let rtl = crate::rtl::flows(ui); let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset); icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a); diff --git a/crates/ui-egui/src/widgets/groups.rs b/crates/ui-egui/src/widgets/groups.rs index ad84305344..9ea90b7ad4 100644 --- a/crates/ui-egui/src/widgets/groups.rs +++ b/crates/ui-egui/src/widgets/groups.rs @@ -1,7 +1,7 @@ #[cfg(test)] use super::TALL_GROUPS; use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line}; -use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2}; +use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2}; /// A ribbon group: content, a centred label below and a divider on the right. pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) { @@ -26,6 +26,8 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& // Not folded: a drop-down left open while it was folded doesn't come back by itself. ui.data_mut(|d| d.remove::(collapsed_open_id(ui, id_title, position))); let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x); + // Reserve the paint slot before children so the glow stays beneath the controls. + let glow = ui.painter().add(egui::Shape::Noop); let inner = crate::rtl::vertical(ui, |ui| { ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 }); crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT); @@ -42,6 +44,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& let _ = content; }); let r = inner.response.rect; + let area = group_area(rtl, start, r); + let over = ui.rect_contains_pointer(area); + // Position is part of identity because duplicate custom group names can appear in tabs. + let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over); + if lit > 0.0 { + ui.painter().set(glow, group_glow(area, t.accent, lit)); + } title_row( ui, Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)), @@ -58,6 +67,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor())); } +/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the +/// area starts at the content's own left edge there. +fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect { + let left = if rtl { content.min.x } else { start.x }; + Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H)) +} + /// The width a group took: the cursor's way along the row, from `before` to `after`. A /// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity, /// so there the width is how far the right edge moved. @@ -65,6 +81,25 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 { if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x } } +/// A soft accent glow along the bottom of a hovered ribbon group. +fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape { + let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8); + let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0)); + let soft = (r.width() * 0.3).min(22.0); + let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x]; + let mut mesh = egui::epaint::Mesh::default(); + for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] { + for (i, x) in xs.iter().enumerate() { + mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha })); + } + } + for i in 0..3u32 { + mesh.add_triangle(i, i + 1, i + 5); + mesh.add_triangle(i, i + 5, i + 4); + } + egui::Shape::mesh(mesh) +} + /// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher. fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) { let t = Tokens::get(ui.ctx()); @@ -264,4 +299,15 @@ mod tests { let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0)); assert_eq!(group_width(false, before, after), 160.0); } + + #[test] + fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() { + let start = pos2(f32::NEG_INFINITY, 10.0); + let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0)); + let area = group_area(true, start, content); + assert!(area.min.x.is_finite() && area.max.x.is_finite()); + assert_eq!((area.min.x, area.max.x), (600.0, 740.0)); + let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0))); + assert!(!area.intersects(next), "neighbouring groups do not overlap"); + } } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index e3777b8e24..3d3e3b684f 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -262,6 +262,9 @@ impl WordApp { quick_access_toolbar: _, recent: _, recent_symbols: _, + date_time_format: _, + date_time_language: _, + date_time_update: _, recent_colors: _, recent_fonts: _, theme: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 4acbed1af7..fefbe96d73 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -33,6 +33,7 @@ use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList use egui::{ViewportCommand, ViewportId}; use serde_json::{Value, json}; use wordcraft_engine::WindowView; +use wordcraft_engine::cmd::view::OtherWindow; use crate::{WordApp, documents, window_ui::WindowUi}; @@ -306,6 +307,17 @@ impl WordApp { (viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx)) } + /// Returns the window key, document ID and display title of the window currently occupying + /// the root viewport, if one exists and native viewports are in use. + pub(crate) fn root_window_info(&self) -> Option<(u64, u64, String)> { + let viewports = self.windows.viewports.as_ref()?; + let root_id = viewports.root()?; + let root_win = self.windows.open.iter().find(|w| w.id == Some(root_id))?; + let doc_id = root_win.doc; + let title = self.session.documents().iter().find(|d| d.id == doc_id).map(documents::display_title).unwrap_or_default(); + Some((root_win.key, doc_id, title)) + } + /// The viewport new windows start from: the host's size, icon and app id. Their title and /// place are the app's. pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { @@ -663,6 +675,15 @@ impl WordApp { self.settle_windows(); if let Some(&active) = self.windows.recent_keys().first() { self.enter_window(active); + self.session.windows.current = Some(active); + if self.session.windows.available { + self.session.windows.others = self + .switch_windows_entries() + .into_iter() + .filter(|(k, ..)| *k != active) + .map(|(id, title, _)| OtherWindow { id, title }) + .collect(); + } } } diff --git a/crates/ui-egui/src/windows/arrange.rs b/crates/ui-egui/src/windows/arrange.rs index 9ee478dd41..5525bb8368 100644 --- a/crates/ui-egui/src/windows/arrange.rs +++ b/crates/ui-egui/src/windows/arrange.rs @@ -71,6 +71,7 @@ pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) { }; let w = &mut app.session.windows; w.available = native; + w.current = Some(key); w.others = others; w.side_by_side = partner; w.sync_scroll = sync; @@ -99,6 +100,11 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) { let key = app.windows.shown; let id = |k: &str| req.get(k).and_then(Value::as_u64); match what { + "switchWindow" => { + if let Some(w) = id("window") { + app.show_window(w); + } + } "sideBySide" => { app.canvas.sync_in = 0.0; if id("off").is_some() { diff --git a/crates/ui-egui/src/windows/tests.rs b/crates/ui-egui/src/windows/tests.rs index fd038cb751..444d353606 100644 --- a/crates/ui-egui/src/windows/tests.rs +++ b/crates/ui-egui/src/windows/tests.rs @@ -386,11 +386,14 @@ fn control_typing_reaches_the_active_window() { assert_eq!(text(&a), "First", "the other window got none of it"); } -/// `ui.screenshot` captures the active window: the capture is sized from its viewport and the -/// command goes to that viewport. A capture still pending when another window comes forward -/// fails with an error instead of capturing that window. +/// `ui.screenshot` captures the window in the root viewport: capturing a secondary document +/// window in a separate native viewport is not supported by the viewport renderer and is refused +/// immediately at admission with an error directing to `view.switchWindows` (#6159). A capture +/// still pending when another window comes forward fails with an error instead of capturing that +/// window. When a window is adopted into the root viewport after the first window closes, it can +/// be captured. #[test] -fn screenshot_captures_the_active_window() { +fn screenshot_captures_the_root_window_and_refuses_secondary_windows() { let ctx = backend(); let mut a = app("First"); frames(&ctx, &mut a, 2); @@ -403,26 +406,102 @@ fn screenshot_captures_the_active_window() { let (reply, replies) = std::sync::mpsc::channel(); crate::control::admit_screenshot(&mut a, &ctx, None, reply); - assert!(replies.try_recv().is_err(), "the capture is admitted"); - assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::>(), [(child, [800, 600])]); + let v = replies.try_recv().expect("a secondary window capture is refused at admission"); + assert_eq!(v["ok"], false, "{v}"); + let err = v["error"].as_str().expect("error message"); + assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error explains why: {err}"); + assert!(err.contains("view.switchWindows {\"window\": 1}"), "error tells how to activate the root window: {err}"); + assert!(a.shots.requests.is_empty(), "refused capture is never queued"); + + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the root window is now active"); + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the capture of the root window is admitted"); + assert_eq!(a.shots.requests.len(), 1); + assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT); a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); COMMANDS.with(|c| c.borrow_mut().clear()); frames(&ctx, &mut a, 4); let shot_to = |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); - assert!(shot_to(child), "the active window is captured"); - assert!(!shot_to(ViewportId::ROOT), "the first window is not"); + assert!(shot_to(ViewportId::ROOT), "the root window is captured"); + assert!(!shot_to(child), "the child window is not"); let (reply, later) = std::sync::mpsc::channel(); crate::control::admit_screenshot(&mut a, &ctx, None, reply); + focus(child); + frames(&ctx, &mut a, 2); + let v = later.try_recv().expect("the capture was answered"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); + assert!(a.shots.requests.is_empty()); + + // When the first window closes, the child window is adopted into the root viewport: + let first_key = a.windows.open.iter().find(|w| w.id == a.windows.viewports.as_ref().unwrap().root()).unwrap().key; + let child_id = a.windows.open.iter().find(|w| w.key != first_key).unwrap().id; + a.close_window(first_key); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 1); + assert_eq!(a.windows.viewports.as_ref().unwrap().root(), child_id); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the adopted window now occupies the root viewport"); + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the adopted window's capture is admitted"); + assert_eq!(a.shots.requests.len(), 1); + assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT); +} + +/// #6159: when two windows are open on the same document (`view.newWindow`), `ui.screenshot` +/// while the secondary window is active is refused at admission. Using `view.switchWindows` with +/// `window` selects the root window, allowing a screenshot retry to be admitted and issued. +#[test] +fn screenshot_refuses_secondary_window_on_same_document_and_recovers_via_switch_windows() { + let ctx = backend(); + let mut a = app("Shared Doc"); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + let child = a.active_viewport(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect)); + focus(child); + frames(&ctx, &mut a, 4); + assert_ne!(a.active_viewport(), ViewportId::ROOT); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + let v = replies.try_recv().expect("secondary window screenshot refused at admission"); + assert_eq!(v["ok"], false, "{v}"); + let err = v["error"].as_str().expect("error message"); + assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error: {err}"); + assert!(err.contains("view.switchWindows {\"window\": 1}"), "error recommends root window selection: {err}"); + + // Recovery path: select the root window via view.switchWindows {"window": 1} + COMMANDS.with(|c| c.borrow_mut().clear()); + a.run("view.switchWindows", json!({"window": 1})).unwrap(); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "switchWindows selected the root window"); + // Simulate the desktop applying the focus request: focus(ViewportId::ROOT); frames(&ctx, &mut a, 2); - for r in [replies, later] { - let v = r.try_recv().expect("the capture was answered"); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); - } - assert!(a.shots.requests.is_empty()); + let root_focused = COMMANDS.with(|c| c.borrow().get(&ViewportId::ROOT).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Focus)))); + assert!(root_focused, "switchWindows requested focus on root viewport"); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "root window is now active"); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "screenshot admitted on retry"); + assert_eq!(a.shots.requests.len(), 1); + assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT); + + a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); + COMMANDS.with(|c| c.borrow_mut().clear()); + frames(&ctx, &mut a, 4); + let shot_to = + |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); + assert!(shot_to(ViewportId::ROOT), "root window screenshot was issued"); + assert!(!shot_to(child), "secondary window was not issued"); } /// Quitting with two documents of unsaved changes asks about each in its window, the window diff --git a/crates/ui-egui/tests/flatpak_updates.rs b/crates/ui-egui/tests/flatpak_updates.rs new file mode 100644 index 0000000000..bc0cf42d6a --- /dev/null +++ b/crates/ui-egui/tests/flatpak_updates.rs @@ -0,0 +1,27 @@ +//! Release and source Flatpaks must allow release checks and host update helpers. +fn finish_args(manifest: &str) -> Vec<&str> { + manifest + .split("finish-args:") + .nth(1) + .unwrap_or_default() + .lines() + .take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#')) + .filter_map(|line| line.trim().strip_prefix("- ")) + .map(|arg| arg.trim_matches(['\'', '"'])) + .collect() +} + +#[test] +fn flatpak_update_permissions() { + let manifests = [ + include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"), + include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.yml"), + ]; + let args = manifests.map(finish_args); + assert_eq!(args[0], args[1], "source and release permissions must agree"); + for permissions in args { + for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] { + assert!(permissions.contains(&required), "missing update permission: {required}"); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6fc0451d7e..5f5d1529cf 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images. +> **Last updated:** 2026-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows. Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`). A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2. @@ -72,9 +72,11 @@ Unauthenticated connections are limited to 64 while they wait for their first au The app's native windows (one per document, or several on one document) take part in View › Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}` places this window and another one in the left and right halves and turns on Synchronous -Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists -and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows -other than the one the command runs in (optional when only one other window is open). The +Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?, window?}` +lists documents and switches documents or windows. For Side by Side, `window` is an id from +`ui.inspect` → `windows.others` and is optional when only one other window is open. For Switch +Windows, `window` is an open window's key. A secondary-window screenshot refusal can include +the root window key in the suggested `view.switchWindows` command. The commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't let apps move windows, so there Arrange All and Side by Side only resize. @@ -93,7 +95,7 @@ let apps move windows, so there Arrange All and Side by Side only resize. | `ui.wheel` | `x`, `y`, `dy`, `dx?`, `unit?` (`point`/`line`/`page`), `phase?` (`start`/`move`/`end`), modifiers | mouse-wheel or trackpad scroll at window coordinates (`dy` < 0 scrolls down) | | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | -| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | +| `ui.screenshot` | `path?` | PNG of the window in the root viewport: written to `path`, or without one returned as base64 `png` in the reply. Capturing a secondary document window is not supported by the viewport renderer; select the root window with `view.switchWindows {"window": root}` first | | `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | | `ui.parity` | — | feature-catalog parity | | `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for | @@ -131,7 +133,7 @@ window); in the browser, the one the window shows. Commands work on it unless to (`"alreadyOpen": true`). Its result carries the document's `id`. - `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", "dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to - the front. + the front. With `{"window": key}` it selects that window directly and brings it to the front. - `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it is the last window. - `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work @@ -154,6 +156,14 @@ Input and window methods go to the active window, the one focused last: `ui.clic `ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To work in another window, make its document active first with `view.switchWindows {"id": n}`. +`ui.screenshot` is the exception when multiple document windows are open: the desktop renderer +captures only the window in the root viewport (the first window opened, until that window closes and +another is adopted in its place). While the active window is a secondary document window in a separate +native viewport, `ui.screenshot` returns an error directing the caller to select the root window +instead of hanging for 8 seconds and timing out. Select the root window with +`view.switchWindows {"window": root}` and request the capture again. The error gives the root +window key when it is available. + The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f834b858cd..7903aa9908 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -6,7 +6,7 @@ > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (ODT columns stating only their shares); previously trivial (ODT font size for every script); previously trivial (ODT text turning off what its style turns on); previously trivial (ODT custom styles with an outline level); previously trivial (ODT pictures keep the size their file gives); previously trivial (ODT frames keep the page they are anchored to); previously trivial (ODT: a document's language kept when its styles are written from the model); previously trivial (ODT table and row backgrounds); previously trivial (ODT row: how tab stops are saved, corrected); previously trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-10 · **Change:** trivial (ODT: a paragraph default style that names no font keeps the document's default font, #4683); previously trivial (ODT columns stating only their shares); previously trivial (ODT font size for every script); previously trivial (ODT text turning off what its style turns on); previously trivial (ODT custom styles with an outline level); previously trivial (ODT pictures keep the size their file gives); previously trivial (ODT frames keep the page they are anchored to); previously trivial (ODT: a document's language kept when its styles are written from the model); previously trivial (ODT table and row backgrounds); previously trivial (ODT row: how tab stops are saved, corrected); previously trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. @@ -338,6 +338,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | ODT: a later paragraph default style that names no font no longer clears the document's default font; a regression from the #4501 refactor (#4683) | | 2026-11-08 | trivial | ODT row corrected: a file whose tab stops are measured from the indent is saved measuring from it, not always from the margin (the 2026-11-03 change); the default stops are counted from the indent | | 2026-11-05 | trivial | ODT: a table's columns stating only their share (`style:rel-column-width`, as LibreOffice's CV template does) keep their proportions; they opened and saved as equal columns. An aligned table's stated width is what they divide, columns with only a width take it first, and the default style can give a share. In an aligned table whose every column has a share, the shares win over any widths stated with them, as LibreOffice 26.8 reads them, and a share too small for a column gives the narrowest one (6 pt) instead of losing the grid. A table in a cell, frame or narrower column with widths stated as well as shares, or with nothing to divide, splits equally as before. | | 2026-11-05 | trivial | ODT: a size is written for Asian and complex-script text too (`style:font-size-asian`, `style:font-size-complex`), as bold and italic are. Chinese text set to 24 pt stayed at the default size in LibreOffice. A complex-script size like the text's stays where a style or default underneath states another, or where a size stated alone over it would show it. A run or paragraph style stating only a size keeps the complex size above it when saved, rather than getting its own size for it. | diff --git a/docs/gaps.md b/docs/gaps.md index bf52f8ad24..f67cd79dff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,7 +2,7 @@ > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture now reaches the system clipboard as PNG, with HTML where the host writes it; a copy of only shapes still stays in WordCraft, #6205); previously trivial (#19: ODT default language kept when styles are written from the model); previously trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture now reaches the system clipboard as PNG, with HTML where the host writes it; a copy of only shapes still stays in WordCraft, #6205); previously trivial (#4501 refactor regressions restored, #4683; no ranked gap changed); previously trivial (#19: ODT default language kept when styles are written from the model); previously trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -199,6 +199,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| | 2026-11-12 | trivial | Clipboard: a copied picture is published to the system clipboard as PNG, with HTML where the host writes it, and WordCraft's own paste keeps the original fragment; a copy of only shapes stays in WordCraft (#6205). Native external-app paste remains untested. | +| 2026-11-04 | trivial | #4501 refactor regressions restored: per-section header/footer grid, ODT default font, link tooltip (#4683); no ranked gap changed; readiness estimates unchanged | | 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open | | 2026-11-08 | trivial | #4 narrowed: a cell's last Normal paragraph drops its contextual space after, the end-of-cell mark counting as a Normal paragraph (#5450) | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1f2f967632..0a39f59cca 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-08 · **Change:** trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (from the second section on, headers, footers and note separators are measured with their own section's document grid and SECTION/SECTIONPAGES values, #4683); previously trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -307,6 +307,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Headers, footers and note separators of the second and later sections are measured with their own section's document grid and SECTION/SECTIONPAGES values, not the previous section's; a regression from the #4501 refactor (#4683). `layout::tests::pages::a_sections_header_is_measured_without_the_previous_sections_grid` | | 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word | | 2026-11-08 | trivial | Contextual spacing drops the space after a table cell's last paragraph in the Normal style, the end-of-cell mark counting as a Normal paragraph; another style keeps it, and a middle or bottom cell of a split row lines up without the dropped space (#5450) (`layout::tests::contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`). | | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e03511b72a..180c895a79 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. @@ -128,6 +128,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| | 2026-11-12 | trivial | Clipboard: a copied picture is published as PNG (and HTML where the host writes it); WordCraft's own paste still keeps the original fragment, and a copy of only shapes carries no HTML or RTF (#6205). Native external-app paste remains untested. | +| 2026-11-04 | trivial | Canvas: a press the canvas handles itself (Ctrl+click on a link, switching into a header or footer, equation entry) skips the link tooltip that frame; hovering shows it again; a regression from the #4501 refactor (#4683) | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index d4cdde71f3..a6fda10641 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -55,7 +55,7 @@ esac # Only binaries for this machine run here (the --version check at the end); a FreeBSD target built # on another OS never does. HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" -if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then HOST_ARCH="$(uname -s) $HOST_ARCH" fi BASENAME="wordcraft-$VERSION-freebsd-$ARCH" @@ -96,12 +96,12 @@ mkdir -p "$DIST" tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" echo "wrote $DIST/$BASENAME.tar.gz" -if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then +if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then "$STAGE/bin/wordcraft-cli" --version else # They cannot run here, so check what they are instead: executables, not libraries or objects. # An older file(1) calls a PIE a shared object; its interpreter still marks it as a program. - want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64 + want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64 for b in "$STAGE"/bin/*; do case "$(file -b "$b")" in ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;; diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml index 694c7138ba..14d0a219a9 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml @@ -17,6 +17,8 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: wordcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index 9ce3934aef..a855e0c110 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -18,6 +18,8 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: wordcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 6bf39eef46..7d8aaec5fa 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -25,7 +25,8 @@ description: |- # The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the # GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here # rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a -# program it starts, so that is a recommendation too. +# program it starts, so that is a recommendation too. Without the X11 libraries the app stays on +# native Wayland rather than starting through XWayland (see `uses_x11` in main.rs). overrides: deb: depends: @@ -37,6 +38,7 @@ overrides: - libwayland-client0 - libx11-6 - libx11-xcb1 + - libxkbcommon-x11-0 - libxcursor1 - libxi6 - libxrandr2 @@ -55,6 +57,8 @@ overrides: recommends: - libwayland-client - libX11 + - libX11-xcb + - libxkbcommon-x11 - libXcursor - libXi - libXrandr