diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cceda4c65..a5cbc378e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ -# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that -# only the next contributor's local run discovers. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -12,51 +11,18 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -env: - # Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a - # release build embeds (the checkout is an optional build input, see crates/fonts/build.rs). - CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0 + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - - name: Install Linux build dependencies (winit, wgpu, rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev + libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 7758cc108..91876c291 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,19 +2,18 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "27 4 * * *" push: branches: [release] pull_request: paths: - - "crates/fonts/**" - .github/workflows/freebsd.yml - packaging/env.sh + schedule: + - cron: "27 4 * * *" workflow_dispatch: concurrency: diff --git a/Cargo.lock b/Cargo.lock index 538b4542f..91a380735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5140,7 +5140,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "chrono", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde_json", "sys-locale", @@ -5187,11 +5187,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.9.0" +version = "0.10.0" [[package]] name = "wordcraft-doc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64", "proptest", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cfb", "chrono", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.9.0" +version = "0.10.0" dependencies = [ "aes", "base64", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cfb", "chrono", @@ -5275,7 +5275,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -5288,7 +5288,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cfb", "chardetng", @@ -5306,14 +5306,14 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "wordcraft-layout" -version = "0.9.0" +version = "0.10.0" dependencies = [ "image", "js-sys", @@ -5333,7 +5333,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.9.0" +version = "0.10.0" dependencies = [ "log", "serde_json", @@ -5345,7 +5345,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.9.0" +version = "0.10.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -5353,7 +5353,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.9.0" +version = "0.10.0" dependencies = [ "chrono", "craft-pdf", @@ -5374,7 +5374,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.9.0" +version = "0.10.0" dependencies = [ "log", "miniz_oxide 0.8.9", @@ -5383,7 +5383,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.9.0" +version = "0.10.0" dependencies = [ "image", "log", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cfb", "craft-ui", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.9.0" +version = "0.10.0" dependencies = [ "log", "proptest", @@ -5556,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 2a7e9aaad..3e08edf80 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.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -91,11 +91,16 @@ todo = "warn" too_many_arguments = "allow" type_complexity = "allow" +# Line tables keep file and line in panics and backtraces; full debug info +# for every test binary and dependency would multiply the target dir size. +# The test profile inherits both settings. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/ROADMAP.md b/ROADMAP.md index ef0e2222a..1188f76ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document. +- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. +- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). +- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). +- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: 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). @@ -143,6 +148,9 @@ Four geometry-dependent layout controls request Source Sans 3 explicitly. Instal Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-21: A word with no break opportunity in it no longer makes the layout quadratic (#3412): measuring it beside a floating object walked the whole paragraph for every line it takes, so a 1 MiB word took 30 s to lay out. The layout is linear now. +- 2026-10-23: A relationship the markup names without the prefix is read as it wrote (#3411 follow-up): a producer that left the prefix out writes the relationship id as a plain `id`, and the reader resolves it the way it resolves the namespaced one, so the part it names is kept with the object that names it and the writer rewrites the attribute with the id the new package gives the part. An ordinary `id` — a VML shape's, a drawing's `wp:docPr`, a picture's `pic:cNvPr` — is the element's own and names no part. +- 2026-10-21: A document with many relationships opens in the time its part takes (#3411): the reader looked for each relationship's id in the main document's part, once per relationship, so a 70 KB file with 1024 relationships and a 64 MiB part took 224 s to open. The ids are now the ones the markup names in a relationship attribute, taken from the main document the reader has already parsed, so an id the text only mentions is no longer a reference and the part it belongs to is no longer dropped on save. The open is linear now. - 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme - 2026-10-21: Table styles shade the same banded rows in Word and WordCraft: our banded styles (Grid Table 4…) and new table styles write their band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`), which Word otherwise reads as zero and leaves unbanded, and a file's style without them is unbanded here too (#3415). - 2026-10-21: Screen readers: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172). The @@ -151,6 +159,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899). - 2026-10-20: Headers and footers: closing them with Esc or Close Header and Footer puts the caret back where it was in the body, as Word does, instead of on page 1 (the view no longer jumps to the top of the document, #3331). - 2026-10-21: A document with many content controls saves in the time the controls take (#13): the writer looked for each paragraph's markers in the whole set of markers, once per paragraph, and compared every control with every other one to find the ones that clash, so a document with 32,000 controls took four seconds to save. Both are a lookup and a sweep now, and what is written does not change. +- 2026-10-20: Restrict Editing: read only and comments-only protection grey out every edit in the ribbon when no exception lets the author edit, and tracked-changes protection greys out Accept and Reject, instead of buttons that look live and refuse when clicked. - 2026-10-20: Restrict Editing: with Filling in forms enforced, the Developer tab's content-control buttons and Properties grey out in the ribbon, their tooltips saying why, as in Word; before, they looked live and only refused when clicked. - 2026-10-20: The page tip shown while dragging the vertical scrollbar stays on one line; it broke into two or three lines ("Page 10 of" / "40") as the page number grew during the drag (#3341). - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. @@ -158,6 +167,17 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. | +| 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back | +| 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open | +| 2026-10-22 | Restrict Editing: the CLI's `--author` uses the same protection check as `file.setAuthor`, and File › Options disables the User name field while the document is protected. | +| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting | +| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | +| 2026-10-17 | Ribbon hints' reasons for a greyed-out control are translated in every interface language; a test fails if a reason it finds in the command sources, or the "Open a document first" hint, has no translation (#2678). | +| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). | +| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | +| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | +| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | | 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. | | 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. | | 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) | @@ -284,6 +304,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips | | 2026-10-18 | Legacy menu keys (#1697; not on macOS): Alt, V, U turns on Full Screen View, as in Word for Windows, along with 17 other Word 2003 menu sequences observed there (views, ruler, zoom, Select All, Find/Replace/Go To, Symbol, Picture, Font, Paragraph, Word Count, Insert Table). Hidden as in Word, with a hint under the tab row while a sequence waits | | 2026-10-18 | View › Focus now fills the screen (#1678): the document on black with a slim bar whose "⋯" drops the ribbon over the page. Full Screen View (`view.fullScreenView`) shows the pages alone with Search at the top and can be bound to any key in Customize Keyboard; the two turn each other off. F11's Full Screen (#980) still fills the screen with the whole window. Esc leaves either; Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon, and a shortcut of your own on Ctrl+F1 wins. Immersive Reader no longer switches Focus on | +| 2026-10-15 | Selection bar mirrors for right-to-left paragraphs (the right margin) and works beside table rows: a click in the page margin beside a row selects the row, a drag selects rows, Shift+click extends by rows (a right-to-left table's bar is on the right); `table.selectAt {"extend"}`. Word's right-pointing arrow still missing | | 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. | | 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) | | 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) | @@ -572,6 +593,17 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) | +| 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) | +| 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) | +| 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) | +| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. | +| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). | +| 2026-10-21 | trivial | Progress log: a word with no break opportunity in it no longer makes the layout quadratic (#3412) | +| 2026-10-20 | minor | The Document Inspector's removals are refused while the document tracks changes (they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back), and a version snapshot belongs to the document it was taken from | +| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting | +| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | +| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | @@ -592,6 +624,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) | | 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings | | 2026-10-20 | trivial | Progress log: closing the header returns to the body selection (#3331) | +| 2026-10-20 | trivial | Progress log: read only and comments-only protection grey out edits | | 2026-10-20 | trivial | Progress log: forms protection greys out the Developer tab's control buttons and Properties | | 2026-10-20 | trivial | Progress log: the scrollbar page tip stays on one line (#3341) | | 2026-10-19 | trivial | Progress log: typing no longer stops after clicking Search (#2986) | @@ -642,6 +675,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) | | 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) | | 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) | +| 2026-10-15 | trivial | Progress log: selection bar for right-to-left paragraphs and table rows | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. | @@ -891,3 +925,5 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) | | 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) | | 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) | +| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | +| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 78e4bbfa1..0c9480752 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -180,6 +180,8 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result { } /// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). +/// The command also checks whether the open document is protected: this name determines which +/// named editing exceptions the session can use, so the flag must not bypass that check. fn set_author(s: &mut Session, name: &str) -> Result<(), String> { s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) } @@ -805,6 +807,31 @@ mod tests { assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok()); // The app's user name is the person's own; a bridge flag must not rewrite it. assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author"))); + // The flag goes through the same protection check as the command. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.author.clone(); + assert!(set_author(&mut s, "Claude").is_err()); + assert_eq!(s.author, before); + assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err()); + } + + #[test] + fn author_flag_cannot_claim_a_named_exception_after_protection() { + use wordcraft_doc::{CharProps, InlineObject, StoryRef}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception.")); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap(); + assert_eq!(s.doc.perm_ranges().len(), 1); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + s.run("select.text", &json!({"text": "Named"})).unwrap(); + assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err()); + + assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception"); + assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err()); } #[test] @@ -1127,7 +1154,7 @@ mod tests { } /// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS - /// (as in `tests/non_utf8_args.rs`). + /// (as in `tests/it/non_utf8_args.rs`). #[cfg(all(unix, not(target_os = "macos")))] #[test] fn script_file_name_need_not_be_unicode() { diff --git a/apps/wordcraft-cli/tests/closed_stdout.rs b/apps/wordcraft-cli/tests/it/closed_stdout.rs similarity index 99% rename from apps/wordcraft-cli/tests/closed_stdout.rs rename to apps/wordcraft-cli/tests/it/closed_stdout.rs index 43dd75992..307c07100 100644 --- a/apps/wordcraft-cli/tests/closed_stdout.rs +++ b/apps/wordcraft-cli/tests/it/closed_stdout.rs @@ -1,6 +1,5 @@ //! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI //! quietly with exit 0, never with a panic (#508). -#![cfg(unix)] use std::io::{Read, Write}; use std::process::{Command, Stdio}; diff --git a/apps/wordcraft-cli/tests/convert_fields.rs b/apps/wordcraft-cli/tests/it/convert_fields.rs similarity index 100% rename from apps/wordcraft-cli/tests/convert_fields.rs rename to apps/wordcraft-cli/tests/it/convert_fields.rs diff --git a/apps/wordcraft-cli/tests/convert_region.rs b/apps/wordcraft-cli/tests/it/convert_region.rs similarity index 100% rename from apps/wordcraft-cli/tests/convert_region.rs rename to apps/wordcraft-cli/tests/it/convert_region.rs diff --git a/apps/wordcraft-cli/tests/help.rs b/apps/wordcraft-cli/tests/it/help.rs similarity index 100% rename from apps/wordcraft-cli/tests/help.rs rename to apps/wordcraft-cli/tests/it/help.rs diff --git a/apps/wordcraft-cli/tests/it/main.rs b/apps/wordcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..61a27c812 --- /dev/null +++ b/apps/wordcraft-cli/tests/it/main.rs @@ -0,0 +1,9 @@ +#[cfg(unix)] +mod closed_stdout; +mod convert_fields; +mod convert_region; +mod help; +#[cfg(all(unix, not(target_os = "macos")))] +mod non_utf8_args; +mod picture_then_typing; +mod text_tracked; diff --git a/apps/wordcraft-cli/tests/non_utf8_args.rs b/apps/wordcraft-cli/tests/it/non_utf8_args.rs similarity index 98% rename from apps/wordcraft-cli/tests/non_utf8_args.rs rename to apps/wordcraft-cli/tests/it/non_utf8_args.rs index 0258f52a9..fef340285 100644 --- a/apps/wordcraft-cli/tests/non_utf8_args.rs +++ b/apps/wordcraft-cli/tests/it/non_utf8_args.rs @@ -1,7 +1,6 @@ //! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the //! file a command opens opens, and any other such argument is an error naming it. Not on macOS: //! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there. -#![cfg(all(unix, not(target_os = "macos")))] use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; diff --git a/apps/wordcraft-cli/tests/picture_then_typing.rs b/apps/wordcraft-cli/tests/it/picture_then_typing.rs similarity index 100% rename from apps/wordcraft-cli/tests/picture_then_typing.rs rename to apps/wordcraft-cli/tests/it/picture_then_typing.rs diff --git a/apps/wordcraft-cli/tests/text_tracked.rs b/apps/wordcraft-cli/tests/it/text_tracked.rs similarity index 100% rename from apps/wordcraft-cli/tests/text_tracked.rs rename to apps/wordcraft-cli/tests/it/text_tracked.rs diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 002354ce2..1d4cb7b0b 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -258,7 +258,9 @@ mod tests { fn an_instant_cancel_retries_then_falls_back() { let quick = Duration::from_millis(200); let slow_cancel = || async { - std::thread::sleep(Duration::from_millis(250)); + // The test polls this future by hand, with no async runtime, so a blocking sleep + // models a slow portal response. + std::thread::sleep(Duration::from_millis(250)); // NOSONAR Vec::::new() }; assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1])); diff --git a/apps/wordcraft/tests/control_arg.rs b/apps/wordcraft/tests/it/control_arg.rs similarity index 100% rename from apps/wordcraft/tests/control_arg.rs rename to apps/wordcraft/tests/it/control_arg.rs diff --git a/apps/wordcraft/tests/help.rs b/apps/wordcraft/tests/it/help.rs similarity index 100% rename from apps/wordcraft/tests/help.rs rename to apps/wordcraft/tests/it/help.rs diff --git a/apps/wordcraft/tests/it/main.rs b/apps/wordcraft/tests/it/main.rs new file mode 100644 index 000000000..9c26dc905 --- /dev/null +++ b/apps/wordcraft/tests/it/main.rs @@ -0,0 +1,4 @@ +mod control_arg; +mod help; +#[cfg(unix)] +mod non_utf8_args; diff --git a/apps/wordcraft/tests/non_utf8_args.rs b/apps/wordcraft/tests/it/non_utf8_args.rs similarity index 99% rename from apps/wordcraft/tests/non_utf8_args.rs rename to apps/wordcraft/tests/it/non_utf8_args.rs index 7a86c72d5..0acada06c 100644 --- a/apps/wordcraft/tests/non_utf8_args.rs +++ b/apps/wordcraft/tests/it/non_utf8_args.rs @@ -1,5 +1,4 @@ //! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440). -#![cfg(unix)] use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; diff --git a/contributors/people.toml b/contributors/people.toml index 7168664b5..03db5f9d2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -175,3 +175,6 @@ display_name = "Mulai" [people.Bortlesboat] real_name = "Andrew Barnes" + +[people.pakiat] +display_name = "OtorO" diff --git a/crates/doc/src/cjk_numbers.rs b/crates/doc/src/cjk_numbers.rs index 64a1757fa..f3b6585c4 100644 --- a/crates/doc/src/cjk_numbers.rs +++ b/crates/doc/src/cjk_numbers.rs @@ -74,6 +74,8 @@ impl Counting { out.push(u); } } + // Trailing zeros of a group are not a gap: the next group starts a new place. + zero_pending = false; match gi { 0 => out.extend(self.myriads.get(1)), 1 => out.extend(self.myriads.first()), @@ -324,6 +326,17 @@ pub(crate) fn chosung(n: u32) -> String { mod tests { use super::*; + #[test] + fn chinese_no_zero_after_group_trailing_zeros() { + assert_eq!(chinese_counting_thousand(201_000), "二十万一千"); + assert_eq!(chinese_counting_thousand(120_000), "十二万"); + assert_eq!(chinese_counting_thousand(100_100), "十万零一百"); + assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千"); + assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一"); + assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万"); + assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一"); + } + #[test] fn chinese_counting_systems() { let c = |n| chinese_counting(n); diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 245dfd520..8ea70e81a 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::graphic::PathSeg; -use crate::para::{GroupChild, InlineObject, ShapeKind}; +use crate::para::{InlineObject, ShapeKind}; use crate::wordart::TextWarp; /// The rest of a shape's properties. Empty (the default) for most shapes. @@ -191,9 +191,16 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32 if at_end { (ux, uy) } else { (-ux, -uy) } } -/// Re-route the glued connectors among a group's members (they share the group's coordinate -/// space). Returns whether any moved. -pub fn reroute_group(children: &mut [GroupChild]) -> bool { +/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child +/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin. +/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped +/// fallback are unchanged. +pub fn reroute_group(group: &mut InlineObject) -> bool { + let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false }; + let fin = wordcraft_geom::finite; + // Match group_rects: invalid child extents use unit scale; invalid display extents become zero. + let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 }; + let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 }; let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children .iter() .filter_map(|c| match &c.obj { @@ -215,9 +222,35 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool { if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) { continue; } - let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); - let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0)); + let (s, e, fallback) = match (find(extra.start), find(extra.end)) { + (Some(s), Some(e)) => (s, e, false), + (s, e) => { + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + continue; + } + // Rendering scales the rectangle before spinning it. Undo only the group scale + // so route can rebuild an unrotated child frame without moving fallback ends. + let (x, y) = (fin(c.x) * sx, fin(c.y) * sy); + let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0)); + let (cx, cy) = (x + cw / 2.0, y + ch / 2.0); + let spin = float.spin(); + let s0 = spin.apply(cx, cy, x, y); + let e0 = spin.apply(cx, cy, x + cw, y + ch); + (s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true) + } + }; + if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) { + continue; + } let f = route(s, e); + if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) { + continue; + } + // group_rects caps displayed child sizes. A larger fallback span cannot be represented + // by an unrotated frame, so keep the original instead of moving its free/unresolved end. + if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) { + continue; + } let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01; if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 { continue; @@ -237,7 +270,7 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::para::Float; + use crate::para::{Float, GroupChild}; fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject { InlineObject::Shape { @@ -255,6 +288,10 @@ mod tests { } } + fn group(children: Vec) -> InlineObject { + InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None } + } + #[test] fn route_and_ends_are_inverse() { for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] { @@ -275,6 +312,174 @@ mod tests { assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0)); } + #[test] + fn rerouting_preserves_transformed_free_and_unresolved_ends() { + for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] { + for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] { + for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] { + for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (free, attached) } else { (attached, free) }; + let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = rot; + float.flip_h = flip_h; + float.flip_v = flip_v; + // Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate + // clockwise in y-down group space. Do not use the routing transform + // to compute the expectation. + let sign = if free_start { -1.0 } else { 1.0 }; + let dx = sign * if flip_h { -20.0 } else { 20.0 }; + let dy = sign * if flip_v { -10.0 } else { 10.0 }; + let (sin, cos) = rot.to_radians().sin_cos(); + let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos); + let kids = vec![ + GroupChild { + x: 100.0, + y: 200.0, + obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }), + }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]; + let mut group = group(kids); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + let (free, attached) = if free_start { (start, end) } else { (end, start) }; + assert!( + (free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001, + "{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}" + ); + assert_eq!(attached, (140.0, 210.0)); + assert_eq!(float.rot, 0.0); + assert!(!reroute_group(&mut group), "rerouting must settle"); + } + } + } + } + } + } + + #[test] + fn rerouting_preserves_both_unresolved_ends() { + let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let kids = vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]; + let mut group = group(kids); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001); + assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001); + assert_eq!(float.rot, 0.0); + assert!(!reroute_group(&mut group)); + } + + #[test] + fn rerouting_nonuniform_group_preserves_both_unresolved_ends() { + let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let mut group = group(vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]); + let InlineObject::Group { w, h, .. } = &mut group else { panic!() }; + (*w, *h) = (400.0, 150.0); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + // Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60). + assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001); + assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001); + assert!(!reroute_group(&mut group)); + } + + #[test] + fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (None, attached) } else { (attached, None) }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.flip_h = free_start; + let mut group = group(vec![ + GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 3000.0, y: 10.0, obj }, + ]); + let InlineObject::Group { w, .. } = &mut group else { panic!() }; + *w = 400.0; + let visual_free = |group: &InlineObject| { + let rects = group.group_rects(0.0, 0.0, 400.0, 300.0); + let ([x, y, w, h], obj) = rects[1]; + let spin = obj.frame().unwrap().2.spin(); + let (px, py) = if free_start { (x, y) } else { (x + w, y + h) }; + spin.apply(x + w / 2.0, y + h / 2.0, px, py) + }; + let before = visual_free(&group); + assert_eq!(before, (6080.0, 10.0)); + let moved = reroute_group(&mut group); + assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints"); + assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged"); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start)); + assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector"); + } + } + + #[test] + fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() { + for dims in [ + [0.0, 300.0, 200.0, 300.0], + [200.0, 0.0, 200.0, 300.0], + [-200.0, 300.0, 200.0, 300.0], + [f32::NAN, 300.0, 200.0, 300.0], + [200.0, f32::INFINITY, 200.0, 300.0], + [f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0], + [f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0], + [20000.0, f32::MIN_POSITIVE, 200.0, 300.0], + [200.0, 300.0, 0.0, 0.0], + [200.0, 300.0, f32::NAN, f32::INFINITY], + [200.0, 300.0, -200.0, -300.0], + ] { + let mut obj = + shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let mut group = group(vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]); + let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() }; + (*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]); + let moved = reroute_group(&mut group); + // group_rects treats invalid child extents as unit scale; display scales that + // cannot be inverted (or whose inverse overflows) must not change the connector. + assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}"); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}"); + assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}"); + } + assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default()))); + } + #[test] fn group_connectors_follow_their_shapes() { let a = ShapeExtra { id: 7, ..Default::default() }; @@ -285,17 +490,40 @@ mod tests { GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) }, GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) }, ]; - assert!(reroute_group(&mut kids)); + // Fully resolved endpoints must still come from their sites, regardless of the + // connector's old rotation and flips. + let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() }; + float.rot = 90.0; + float.flip_h = true; + float.flip_v = true; + let mut group = group(kids); + assert!(reroute_group(&mut group)); // From the rectangle's right side (20, 5) to the ellipse's left side (100, 55). - let c = &kids[2]; + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[2]; let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false)); - assert!(!reroute_group(&mut kids), "settled"); + assert!(!reroute_group(&mut group), "settled"); + // A fully resolved connector does not need an invertible group scale. + for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] { + let mut scaled = group.clone(); + let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() }; + (*w, *h) = (width, height); + let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() }; + float.rot = 90.0; + assert!(reroute_group(&mut scaled)); + let InlineObject::Group { children, .. } = &scaled else { panic!() }; + let c = &children[2]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0))); + assert!(!reroute_group(&mut scaled)); + } // A connection to a missing id or site keeps that end where it was. - if let InlineObject::Shape { extra, .. } = &mut kids[2].obj { + let InlineObject::Group { children, .. } = &mut group else { panic!() }; + if let InlineObject::Shape { extra, .. } = &mut children[2].obj { extra.end = Some(ConnEnd { id: 404, site: 0 }); extra.start = Some(ConnEnd { id: 7, site: 99 }); } - assert!(!reroute_group(&mut kids)); + assert!(!reroute_group(&mut group)); } } diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index 569ab0a4b..072b50a2b 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -102,6 +102,9 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option { return None; } let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?; + if !v.is_finite() { + return None; + } Some(if neg { -v } else { v }) } @@ -151,6 +154,15 @@ mod tests { assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign"); } + #[test] + fn overflowing_digit_strings_are_not_numbers() { + assert_eq!(parse_number(&"1".repeat(350), None), None); + assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None); + assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None); + let big = parse_number(&"1".repeat(300), None); + assert!(big.is_some_and(f64::is_finite), "{big:?}"); + } + #[test] fn cuban_and_peruvian_numbers_use_decimal_points() { for lang in [Some("es-CU"), Some("es-PE")] { diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 3bfbdb74f..3031f7420 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -193,7 +193,8 @@ impl Table { match idx { Some((i, true)) => { if let Some(c) = row.cells.get_mut(i) { - c.props.span += 1; + // From the clamped span: a `.json` file can carry any `u32` (#3637). + c.props.span = (c.span() + 1).min(MAX_COLS) as u32; } } Some((i, false)) => { @@ -223,7 +224,7 @@ impl Table { for (i, c) in row.cells.iter_mut().enumerate() { if g < x + c.span() { if c.span() > 1 { - c.props.span -= 1; + c.props.span = (c.span() - 1) as u32; } else { rm = Some(i); } @@ -247,7 +248,7 @@ impl Table { let removed: Vec = row.cells.drain(c0 + 1..=c1).collect(); if let Some(first) = row.cells.get_mut(c0) { for c in removed { - first.props.span += c.props.span.max(1); + first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32; let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect(); first.blocks.extend(nonempty); } @@ -549,6 +550,32 @@ mod tests { t.delete_col(99); } + /// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it + /// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS. + #[test] + fn a_huge_span_from_a_file_does_not_overflow() { + let huge = || { + let mut t = Table::new(1, 2, 200.0); + t.rows[0].cells[0].props.span = u32::MAX; + t + }; + // Column 1 is inside the spanning cell, so the cell widens. + let mut t = huge(); + t.insert_col(1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.merge_right(0, 0, 1); + assert_eq!(t.rows[0].cells.len(), 1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.rows[0].cells[1].props.span = u32::MAX; + t.merge_right(0, 0, 1); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32); + let mut t = huge(); + t.delete_col(0); + assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1); + } + #[test] fn merge_and_split() { let mut t = Table::new(2, 3, 300.0); diff --git a/crates/docbin/src/crypt.rs b/crates/docbin/src/crypt.rs index c909bc827..ee8a9477a 100644 --- a/crates/docbin/src/crypt.rs +++ b/crates/docbin/src/crypt.rs @@ -90,8 +90,11 @@ pub(crate) fn md5(data: &[u8]) -> [u8; 16] { out } +/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation. +/// The digest is part of the file format, not a choice made here: a stronger hash would +/// make password-protected `.doc` files written by Word unreadable. fn sha1(parts: &[&[u8]]) -> Vec { - let mut h = Sha1::new(); + let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format for p in parts { h.update(p); } diff --git a/crates/docbin/src/notes.rs b/crates/docbin/src/notes.rs index 89b1db052..dac259099 100644 --- a/crates/docbin/src/notes.rs +++ b/crates/docbin/src/notes.rs @@ -89,29 +89,105 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32, out } -/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data). +/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not +/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped. +/// Names are kept to 40 characters, but the reader always advances by the stored length. fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option> { let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; - if b.len() < 6 { - return None; - } - let c_data = u16::from_le_bytes([b[2], b[3]]) as usize; + let extended = b.get(0..2)? == [0xFF, 0xFF]; + let head = if extended { 2 } else { 0 }; + let h = b.get(head..head + 4)?; + let c_data = u16::from_le_bytes([h[0], h[1]]) as usize; + let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize; if c_data > MAX { return None; } - let mut at = 6usize; + let mut at = head + 4; let mut out = Vec::with_capacity(c_data); for _ in 0..c_data { - let cch = match b.get(at..at + 2) { - Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize, - None => break, + let (cch, unit) = if extended { + match b.get(at..at + 2) { + Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2), + None => break, + } + } else { + match b.get(at) { + Some(&x) => (x as usize, 1), + None => break, + } + }; + at += unit; + let Some(chars) = b.get(at..at + cch * unit) else { break }; + at += cch * unit; + // The string's extra data must be there too, or the table is cut short. + if b.get(at..at + cb_extra).is_none() { + break; + } + at += cb_extra; + let s: String = if extended { + chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect() + } else { + chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect() }; - at += 2; - let cch = cch.min(40); - let chars = b.get(at..at + cch * 2)?; - at += cch * 2; - let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect(); out.push(s); } Some(out) } + +#[cfg(test)] +mod tests { + use super::sttb_strings; + + fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec { + let mut b = vec![0xFF, 0xFF]; + b.extend_from_slice(&(names.len() as u16).to_le_bytes()); + b.extend_from_slice(&(extra.len() as u16).to_le_bytes()); + for n in names { + let units: Vec = n.encode_utf16().collect(); + b.extend_from_slice(&(units.len() as u16).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(extra); + } + b + } + + #[test] + fn long_bookmark_name_does_not_desync_table() { + let long = "A".repeat(50); + let b = ext_sttb(&[&long, "bm2"], &[]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]); + } + + #[test] + fn sttb_extra_data_is_skipped() { + let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]); + } + + #[test] + fn a_name_without_its_extra_data_is_dropped() { + let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + b.truncate(b.len() - 2); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string()]); + } + + #[test] + fn non_extended_sttb_reads_byte_strings() { + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + for n in ["one", "caf\u{e9}"] { + let bytes: Vec = n.chars().map(|c| c as u8).collect(); + b.push(bytes.len() as u8); + b.extend_from_slice(&bytes); + b.extend_from_slice(&[9, 9]); + } + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]); + } +} diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index fca444c22..6c3b0bcef 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -9,7 +9,7 @@ //! or not (a repeated name), data is checked before it's copied, and nesting and elements are //! capped as the XML tree reader caps a part's. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use base64::Engine as _; use quick_xml::events::{BytesStart, Event}; @@ -340,14 +340,21 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec { let rest = frag.get(open..).unwrap_or_default(); let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len()); let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len()); - let tag = rest.get(..tag_end).unwrap_or_default(); + let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default()); + let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1)); + let declared: HashSet> = start + .attributes() + .with_checks(false) + .flatten() + .filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:")) + .map(|a| a.key.as_ref().to_vec()) + .collect(); let mut out = Vec::with_capacity(rest.len() + 256); out.extend_from_slice(rest.get(..name_end).unwrap_or_default()); // Innermost declarations win: walk from the data element out, skipping prefixes seen. let mut seen: Vec<&str> = Vec::new(); for (k, v) in decls.iter().rev() { - let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' '))); - if declared || seen.contains(&k.as_str()) { + if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) { continue; } seen.push(k); @@ -463,6 +470,15 @@ mod tests { assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec()); } + #[test] + fn inherited_namespaces_do_not_rescan_a_long_root_tag() { + let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::>()]; + let frag = format!("", " a=\"1\"".repeat(4_000_000)); + let out = with_scope(frag.as_bytes(), &scope); + let out = String::from_utf8(out).unwrap(); + assert_eq!(out.matches("xmlns:p").count(), 256); + } + #[test] fn nesting_is_capped_where_the_xml_tree_reader_stops() { let inline = |levels: usize| format!("{}{}", "".repeat(levels), "".repeat(levels)); diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs index c1e3487eb..a6b7e1f6e 100644 --- a/crates/docx/src/read/chart.rs +++ b/crates/docx/src/read/chart.rs @@ -471,6 +471,9 @@ impl Series { } } +/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored. +const MAX_FORMAT_CODE: usize = 255; + /// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's /// format code (dates included); text and `General` numbers as they are stored. fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec { @@ -478,7 +481,8 @@ fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec { let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else { return Vec::new(); }; - let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General")); + let code = + cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General")); pts(cache, cap) .into_iter() .map(|p| { @@ -505,7 +509,7 @@ fn numbers(el: Option<&El>, cap: usize) -> (Vec>, Option) { let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else { return (Vec::new(), None); }; - let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty()); + let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE); let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite())).collect(); (vals, fmt) } @@ -541,7 +545,12 @@ fn axis(a: Option<&El>) -> Axis { mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"), min: num("c:min"), max: num("c:max"), - fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string), + fmt: a + .child("c:numFmt") + .filter(|f| f.attr("sourceLinked") != Some("1")) + .and_then(|f| f.attr("formatCode")) + .filter(|f| f.len() <= MAX_FORMAT_CODE) + .map(str::to_string), } } @@ -2174,6 +2183,44 @@ mod tests { assert_eq!(ymd(45292.0), Some((2024, 1, 1))); } + #[test] + fn category_format_code_length_is_capped() { + let cat = |code: &str| -> Vec { + let x = format!( + r#"{code}5"# + ); + labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false) + }; + let ok = format!("{}0", "x".repeat(254)); + assert!(cat(&ok)[0].starts_with("xxx")); + let long = format!("{}0", "x".repeat(300)); + assert_eq!(cat(&long), ["5"]); + } + + #[test] + fn value_format_code_length_is_capped() { + let val = |code: &str| -> Option { + let x = format!( + r#"{code}5"# + ); + numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1 + }; + let ok = format!("{}0", "x".repeat(254)); + assert_eq!(val(&ok), Some(ok.clone())); + assert_eq!(val(&format!("{}0", "x".repeat(300))), None); + } + + #[test] + fn axis_format_code_length_is_capped() { + let fmt = |code: &str| -> Option { + let x = format!(r#""#); + axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt + }; + let ok = format!("{}0", "x".repeat(254)); + assert_eq!(fmt(&ok), Some(ok.clone())); + assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None); + } + #[test] fn civil_is_exact_beyond_chronos_range() { // Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`. diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index d3c8d75c7..678db30f0 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -9,6 +9,7 @@ //! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds //! are kept the same way. +use std::collections::HashSet; use std::sync::Arc; use wordcraft_doc::graphic::{Embedded, EmbeddedRef}; @@ -17,7 +18,7 @@ use super::Reader; use crate::package::{ CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt, }; -use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc}; +use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc, is_rel_attr}; /// Relationship types (last path segment) the reader models or the writer produces itself, so /// they are never carried verbatim: carrying them would duplicate or contradict the written part. @@ -64,6 +65,51 @@ pub const HANDLED_RELS: &[&str] = &[ /// Most relationship ids one object's markup may use. const MAX_IDS: usize = 64; +/// The relationship ids the main document's markup refers to: the ids it names in a +/// relationship attribute (`r:id="rId5"`, VML's `o:relid`) — the same rule the embedded objects +/// use ([`rel_ids`]). +/// +/// One pass over the markup the reader has already parsed, not one search per id: the ids are +/// looked up as they are met, so the cost is the size of the document rather than its size +/// times the number of relationships (#12). A name that is not a relationship is ignored, and a +/// name that is not one of the document's relationship ids is not collected. An id that only +/// appears as text is not a reference: the text does not name a part, and treating it as one +/// lost the part on save. +fn referenced_ids<'a>(root: &El, document: &'a Rels) -> HashSet<&'a [u8]> { + // The ids the loop in `keep_unmodelled` can ask about: the first `MAX_EMBEDDED_RELS` + // non-external relationships, and no more, so the set stays bounded however many the file + // carries. + let ids: HashSet<&[u8]> = document.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS).map(|r| r.id.as_bytes()).collect(); + let mut found = HashSet::new(); + if ids.is_empty() { + return found; + } + collect_rel_ids(root, &ids, &mut found, 0); + found +} + +/// The ids `e` names in a relationship attribute that are in `ids`, collected into `found`. +/// +/// The attribute is a relationship attribute by [`is_rel_attr`]: one in the relationship +/// namespace, VML's `o:relid`, or the unqualified alias of one on an element that carries a +/// relationship — the same attribute the reader resolves a relationship from, so a part the +/// markup names is not carried over a second time. +fn collect_rel_ids<'a>(e: &El, ids: &HashSet<&'a [u8]>, found: &mut HashSet<&'a [u8]>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for (k, v) in &e.attrs { + if is_rel_attr(&e.name, k) + && let Some(id) = ids.get(v.as_bytes()) + { + found.insert(*id); + } + } + for c in e.els() { + collect_rel_ids(c, ids, found, depth + 1); + } +} + impl Reader<'_> { /// Keep custom XML stores reached from the main document or package, without evaluating /// bindings. A bibliography already loaded into the source model belongs to its writer. @@ -99,13 +145,12 @@ impl Reader<'_> { /// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any /// relationship the main document's markup refers to by id (its target goes with the /// element, or not at all). - pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) { - let pkg = self.pkg; - let main = pkg.get(&self.main).unwrap_or_default(); - let referenced = |id: &str| { - let needle = format!("\"{id}\""); - main.windows(needle.len()).any(|w| w == needle.as_bytes()) - }; + pub(super) fn keep_unmodelled(&mut self, root: &El, package: &Rels, document: &Rels) { + // The ids the main document's markup refers to, in one pass over the markup: an id is + // named in a relationship attribute (`r:id="rId5"`). One scan for all of them, not one + // search per relationship — a large document with many relationships took minutes to + // open (#12). + let referenced = referenced_ids(root, document); let mut roots = String::new(); // One glossary per main document (its first relationship; see `read_glossary`). let mut glossary = false; @@ -113,7 +158,7 @@ impl Reader<'_> { for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) { let tail = rel.kind.rsplit('/').next().unwrap_or(""); if HANDLED_RELS.contains(&tail) - || (owner == "D" && referenced(&rel.id)) + || (owner == "D" && referenced.contains(rel.id.as_bytes())) || (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true)) || ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { @@ -215,14 +260,15 @@ impl Reader<'_> { } } -/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's -/// `o:relid`), in document order. `None` past [`MAX_IDS`]. +/// The relationship ids `e` uses (attributes in the relationships namespace, VML's `o:relid`, +/// and the unqualified alias of one on an element that carries a relationship), in document +/// order. `None` past [`MAX_IDS`]. fn rel_ids(e: &El, out: &mut Vec, depth: usize) -> Option<()> { if depth > MAX_DEPTH { return Some(()); } for (k, v) in &e.attrs { - if (k.starts_with("r:") || k == "o:relid") && !out.contains(v) { + if is_rel_attr(&e.name, k) && !out.contains(v) { if out.len() >= MAX_IDS { return None; } @@ -247,3 +293,122 @@ fn standalone_xml(e: &El) -> Option { s.push_str(rest); (s.len() <= MAX_EMBEDDED_XML).then_some(s) } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use crate::package::UNMODELLED_ROOTS; + + const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; + const ROOT_RELS: &str = r#""#; + const CONTENT_TYPES: &str = r#""#; + + /// A package whose main part holds `body` (the XML inside `w:body`), and which relates to + /// `rels` parts of a type the reader doesn't model, so each one is looked for in the part. + fn package(body: &str, rels: &[(&str, &str)], parts: &[(&str, &[u8])]) -> Vec { + let doc = format!(r#"{body}"#); + let mut r = String::from(r#""#); + for (id, target) in rels { + r.push_str(&format!( + r#""# + )); + } + r.push_str(""); + + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zw.start_file("[Content_Types].xml", opts).unwrap(); + zw.write_all(CONTENT_TYPES.as_bytes()).unwrap(); + zw.start_file("_rels/.rels", opts).unwrap(); + zw.write_all(ROOT_RELS.as_bytes()).unwrap(); + zw.start_file("word/document.xml", opts).unwrap(); + zw.write_all(doc.as_bytes()).unwrap(); + zw.start_file("word/_rels/document.xml.rels", opts).unwrap(); + zw.write_all(r.as_bytes()).unwrap(); + for (name, bytes) in parts { + zw.start_file(name, opts).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() + } + + /// The parts the reader carries over unchanged, as one string. + fn unmodelled_roots(d: &wordcraft_doc::Document) -> String { + d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default() + } + + /// Opening a document costs the size of its main part, not that size times the number of its + /// relationships (#12). Eight megabytes of main part with a thousand relationships used to + /// take half a minute; the scan is now one pass over the markup. + #[test] + fn a_document_with_many_relationships_opens_in_the_time_its_part_takes() { + let rels: Vec<(String, String)> = (0..1024).map(|i| (format!("rIdT{i}"), format!("file{i}.bin"))).collect(); + let rels: Vec<(&str, &str)> = rels.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect(); + let body = format!(r#"{}"#, "a".repeat(8 << 20)); + let bytes = package(&body, &rels, &[]); + let t = std::time::Instant::now(); + let doc = crate::read(&bytes).unwrap(); + let took = t.elapsed(); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).len(), 8 << 20, "the text is there"); + assert!(took.as_secs() < 15, "opening it took {took:?}"); + } + + /// The scan finds the relationships the markup names, so a part the document already refers + /// to is not carried over a second time (#12). + #[test] + fn a_relationship_the_markup_names_is_not_carried_over_again() { + // The markup names the relationship, so it is not carried over a second time as an + // unmodelled part. (The part itself survives only if the writer writes it with the + // element that names it; the reader does not carry it on its own.) + let named = package( + r#"before"#, + &[("rIdDoc1", "keep.bin")], + &[("word/keep.bin", b"kept")], + ); + let roots = unmodelled_roots(&crate::read(&named).unwrap()); + assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}"); + + // A part the markup doesn't name is carried over, as before. + let unnamed = package(r#"no mention here"#, &[("rIdDoc1", "keep.bin")], &[("word/keep.bin", b"kept")]); + let roots = unmodelled_roots(&crate::read(&unnamed).unwrap()); + assert!(roots.contains("keep.bin"), "the part is dropped: {roots}"); + } + + /// An id the markup only mentions as text does not name a part: the part is carried over, + /// not dropped because the text looked like a reference. + #[test] + fn a_part_the_markup_only_mentions_is_carried_over() { + let mentioned = package( + r#"before "rIdDoc1" after"#, + &[("rIdDoc1", "keep.bin")], + &[("word/keep.bin", b"kept")], + ); + let roots = unmodelled_roots(&crate::read(&mentioned).unwrap()); + assert!(roots.contains("keep.bin"), "the part is dropped: {roots}"); + } + + /// An unqualified attribute is a relationship only where the markup names a relationship + /// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own. + #[test] + fn an_unqualified_attribute_names_a_relationship_only_where_it_is_one() { + // A hyperlink whose relationship id is written without the prefix: the reader resolves + // it (the alias `El::attr` accepts), so the part it names is not carried over twice. + let named = package( + r#"link"#, + &[("rIdDoc1", "keep.bin")], + &[("word/keep.bin", b"kept")], + ); + let roots = unmodelled_roots(&crate::read(&named).unwrap()); + assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}"); + + // A VML shape's `id` is its own: it names no part, so the part is carried over. + let own = package( + r#""#, + &[("rIdDoc1", "keep.bin")], + &[("word/keep.bin", b"kept")], + ); + let roots = unmodelled_roots(&crate::read(&own).unwrap()); + assert!(roots.contains("keep.bin"), "the part is dropped: {roots}"); + } +} diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index 4881cf7a3..457646581 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -256,6 +256,10 @@ fn vml_commands(path: &str) -> Vec<(String, Vec)> { }; while let Some(c) = chars.next() { if c.is_ascii_alphabetic() { + // Cap the commands too, so a path of bare letters can't build a million entries. + if out.len() >= MAX_VML_PARAMS { + break; + } if !has_num { push(&mut out, 0.0); } @@ -361,6 +365,13 @@ mod tests { crate::xml::parse(xml.as_bytes()).expect("parse spPr") } + #[test] + fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() { + let path = "x".repeat(MAX_VML_PATH); + let n = vml_commands(&path).len(); + assert!(n <= MAX_VML_PARAMS, "{n} commands"); + } + #[test] fn a_bezier_segment_with_many_points_is_bounded() { // One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 8bdb65933..302c30ae7 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -299,7 +299,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { } r.read_glossary(&rels); r.keep_custom_xml(&root_rels, &rels); - r.keep_unmodelled(&root_rels, &rels); + r.keep_unmodelled(&root, &root_rels, &rels); if !r.embedded.parts.is_empty() { let manifest = r.embedded.to_bytes(); r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest)); diff --git a/crates/docx/src/wordml2003_write.rs b/crates/docx/src/wordml2003_write.rs index 280d0e161..cf99b291d 100644 --- a/crates/docx/src/wordml2003_write.rs +++ b/crates/docx/src/wordml2003_write.rs @@ -18,7 +18,7 @@ //! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the //! parser's depth limit, pictures and stories are capped in number. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use base64::Engine as _; use wordcraft_doc::Document; @@ -38,6 +38,8 @@ const W10_NS: &str = "urn:schemas-microsoft-com:office:word"; const MAX_MEDIA: usize = 4096; /// Most header, footer, footnote, endnote and comment stories written. const MAX_STORIES: usize = 50_000; +/// Most bytes of header and footer parts inlined, all sections together. +const MAX_INLINED_BYTES: usize = 64 << 20; /// A picture's longest side, points (a corrupt extent must not give a huge frame). const MAX_PICT_PT: f64 = 31_680.0; @@ -63,6 +65,9 @@ pub fn write(doc: &Document) -> Result, DocxError> { } None => Vec::new(), }; + if c.over_budget { + return Err(DocxError::Limit(format!("headers and footers inlined past {MAX_INLINED_BYTES} bytes"))); + } let mut kids: Vec = vec![Node::El(core_props(doc))]; if let Some(f) = c.default_fonts() { @@ -153,6 +158,14 @@ struct Conv<'a> { next_shape: i64, stories: usize, in_note: bool, + /// Notes and comments already inlined: (kind, id). A repeated reference does not copy the body again. + inlined_notes: HashSet<(char, String)>, + /// The header or footer in effect per (footer, type), as the previous sections left it. + hf_in_effect: HashMap<(bool, &'static str), String>, + /// Bytes of header and footer parts inlined so far. + inlined_bytes: usize, + /// A header or footer did not fit in `MAX_INLINED_BYTES`; the export fails. + over_budget: bool, } impl<'a> Conv<'a> { @@ -197,6 +210,10 @@ impl<'a> Conv<'a> { next_shape: 1025, stories: 0, in_note: false, + inlined_notes: HashSet::new(), + hf_in_effect: HashMap::new(), + inlined_bytes: 0, + over_budget: false, } } @@ -368,10 +385,14 @@ impl<'a> Conv<'a> { return Vec::new(); } let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() }; + let name = if foot { "w:footnote" } else { "w:endnote" }; + // The body goes at the first reference only; a repeated one keeps its mark, empty. + if !self.inlined_notes.insert((if foot { 'f' } else { 'e' }, id.to_string())) { + return vec![Node::El(plain(name, Vec::new()))]; + } self.stories += 1; let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() }; self.in_note = true; - let name = if foot { "w:footnote" } else { "w:endnote" }; let kids = self.kids(¬e, &rels, name, depth); self.in_note = false; vec![Node::El(plain(name, kids))] @@ -390,6 +411,10 @@ impl<'a> Conv<'a> { return Vec::new(); } let Some(c) = self.comments.get(id).cloned() else { return Vec::new() }; + // A comment is written once, at its first reference. + if !self.inlined_notes.insert(('c', id.to_string())) { + return Vec::new(); + } self.stories += 1; let rels = self.comment_rels.clone(); let n = self.ann_id('c', id); @@ -448,14 +473,27 @@ impl<'a> Conv<'a> { continue; } let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; - let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue }; - self.stories += 1; - let story_rels = self.pkg.rels(&path); let ty = match k.attr("w:type") { Some("even") => "even", Some("first") => "first", _ => "odd", }; + // A section without the story inherits the previous section's, as in OOXML: write + // it only where it changes. + if self.hf_in_effect.get(&(footer, ty)) == Some(&path) { + continue; + } + let size = self.pkg.get(&path).map_or(0, |b| b.len()); + if self.inlined_bytes.saturating_add(size) > MAX_INLINED_BYTES { + // Skipping would leave later sections inheriting the wrong story: fail instead. + self.over_budget = true; + continue; + } + let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue }; + self.stories += 1; + self.inlined_bytes = self.inlined_bytes.saturating_add(size); + self.hf_in_effect.insert((footer, ty), path.clone()); + let story_rels = self.pkg.rels(&path); let name = if footer { "w:ftr" } else { "w:hdr" }; let was = std::mem::replace(&mut self.in_note, true); let kids = self.kids(&root, &story_rels, name, depth + 1); diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index f04f83ee3..56f3a7898 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -121,6 +121,8 @@ impl<'d> EmbedWriter<'d> { /// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`. fn alloc(&mut self, orig: &str) -> String { + let clean = valid_part_name(orig); + let orig = clean.as_str(); // Suffixes cannot escape reserved directories or a .rels extension. Such a kept // part needs a different base name, otherwise every candidate would be reserved. let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig }; @@ -585,17 +587,12 @@ fn set_attr(e: &mut El, k: &str, v: &str) { } } -/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)? -fn is_rel_attr(k: &str) -> bool { - k.starts_with("r:") || k == "o:relid" -} - fn rel_attr_values(e: &El, out: &mut Vec, depth: usize) { if depth > MAX_DEPTH { return; } for (k, v) in &e.attrs { - if is_rel_attr(k) && !out.contains(v) { + if xml::is_rel_attr(&e.name, k) && !out.contains(v) { out.push(v.clone()); } } @@ -608,8 +605,9 @@ fn set_rel_ids(e: &mut El, ids: &HashMap, depth: usize) { if depth > MAX_DEPTH { return; } + let name = e.name.clone(); for (k, v) in e.attrs.iter_mut() { - if is_rel_attr(k) + if xml::is_rel_attr(&name, k) && let Some(n) = ids.get(v.as_str()) { *v = n.clone(); @@ -684,6 +682,29 @@ fn pt(v: f32) -> String { s.trim_end_matches('0').trim_end_matches('.').to_string() } +/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a +/// dot, or encoded separators. +fn valid_part_name(name: &str) -> String { + let mut out = Vec::new(); + for seg in name.split('/') { + let mut seg = seg.replace('\\', "_"); + for enc in ["%2f", "%5c"] { + while let Some(i) = seg.to_ascii_lowercase().find(enc) { + seg.replace_range(i..i + 3, "_"); + } + } + if seg.is_empty() || seg == "." || seg == ".." { + continue; + } + // A segment can't end in a dot either (`word/a.`). + if seg.ends_with('.') { + seg = format!("{}_", seg.trim_end_matches('.')); + } + out.push(seg); + } + if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") } +} + #[cfg(test)] mod tests { use super::*; @@ -714,4 +735,16 @@ mod tests { // Unturned: no rotation or flip left behind. assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt"); } + + #[test] + fn kept_part_names_are_valid_opc_names() { + let doc = wordcraft_doc::Document::default(); + let mut w = EmbedWriter::new(&doc); + for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] { + let name = w.alloc(orig); + assert!(!name.contains('\\'), "{name}"); + assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}"); + assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}"); + } + } } diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index c3f02cc51..7e8601902 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -116,6 +116,9 @@ pub(crate) struct Writer<'d> { used_media: std::collections::BTreeSet, /// Bounds writing text boxes inside text boxes (as layout shows them). boxes: wordcraft_doc::BoxBudget, + /// Text box stories already written: a story shown by many shapes is written once, the other + /// shapes get an empty box, so the file doesn't grow as shapes × story size. + written_boxes: std::collections::HashSet, /// Charts, diagrams and OLE objects kept from a file: the parts they need. embeds: embed::EmbedWriter<'d>, /// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding @@ -175,6 +178,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result` that ends the tag at the start of `s`, skipping quoted attribute values. +fn tag_end(s: &str) -> Option { + let mut quote = None; + for (i, c) in s.char_indices() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some(_), _) => {} + (None, '"' | '\'') => quote = Some(c), + (None, '>') => return Some(i), + _ => {} + } + } + None +} + /// The root start tag's span end and the root's direct children, or `None` for anything the /// scanner doesn't expect (CDATA, DOCTYPE, a different root). fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec)> { let open = xml.find(&format!("<{root}"))?; - let open_end = open + xml.get(open..)?.find('>')? + 1; + let open_end = open + tag_end(xml.get(open..)?)? + 1; if xml.get(open_end - 2..open_end)? == "/>" { return None; } @@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec)> { i = at + tail.find("?>")? + 2; continue; } - let gt = at + tail.find('>')? + 1; + let gt = at + tag_end(tail)? + 1; let tag = xml.get(at..gt)?; if tag.starts_with(""#, GEN.as_bytes()).is_none()); } + #[test] + fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() { + for v in [r#"w:val="x>y""#, "w:val='x>y'"] { + let orig = format!( + r#""# + ); + assert!(has_unowned(orig.as_bytes()), "{v}"); + let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap(); + assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}"); + } + } + #[test] fn a_compat_block_without_a_mode_gets_the_generated_one() { let orig = r#""#; diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 4fc8d4af8..7f3ddc450 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1126,8 +1126,12 @@ impl Writer<'_> { None => w.open("wps:txbx", &[]), } w.open("w:txbxContent", &[]); - let blocks = part.blocks.clone(); - self.blocks(w, &blocks, rels, false, depth + 1); + if self.written_boxes.insert(id) { + let blocks = part.blocks.clone(); + self.blocks(w, &blocks, rels, false, depth + 1); + } else { + w.empty("w:p", &[]); + } w.close("w:txbxContent"); w.close("wps:txbx"); self.boxes.leave(); diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index c517f3669..d1aa38a91 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -116,6 +116,52 @@ pub enum Node { Text(String), } +/// Elements whose markup names a relationship: the id is in an attribute of the relationship +/// namespace (`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`…) or VML's `o:relid`, and — from a +/// producer that left the prefix out — in the unqualified attribute of the same local name, the +/// alias [`El::attr`] accepts. On any other element an unqualified `id` is the element's own +/// (a drawing's `wp:docPr`, a picture's `pic:cNvPr`, a VML shape, a page background) and names +/// no part. +const REL_ELEMENTS: &[&str] = &[ + "a:blip", + "a:hlinkClick", + "a:hlinkHover", + "a:audioFile", + "a:videoFile", + "c:chart", + "cx:chart", + "dgm:relIds", + "o:OLEObject", + "v:fill", + "v:imagedata", + "w:attachedTemplate", + "w:control", + "w:embedBold", + "w:embedBoldItalic", + "w:embedItalic", + "w:embedRegular", + "w:footerReference", + "w:headerReference", + "w:hyperlink", + "w:object", + "w:subDoc", + "w14:contentPart", +]; + +/// The local names a relationship attribute has, so an unqualified one is recognised as the +/// relationship id it names. +const REL_ALIASES: &[&str] = &["id", "embed", "link", "pict", "dm", "lo", "qs", "cs", "relid"]; + +/// Is the attribute `k` of the element named `element` a relationship id? +/// +/// The relationship namespace (`r:`) and VML's `o:relid` always are. An unqualified attribute +/// is one only on an element that carries a relationship ([`REL_ELEMENTS`]) and only with the +/// local name of a relationship attribute ([`REL_ALIASES`]) — the alias [`El::attr`] accepts. +/// Everywhere else an unqualified `id` is the element's own and names no part. +pub fn is_rel_attr(element: &str, k: &str) -> bool { + k.starts_with("r:") || k == "o:relid" || (REL_ELEMENTS.contains(&element) && REL_ALIASES.contains(&k)) +} + /// An element with canonical-prefixed name and attributes. #[derive(Clone, Debug, PartialEq, Default)] pub struct El { @@ -521,6 +567,26 @@ pub fn body_ns() -> Vec<(String, String)> { mod tests { use super::*; + #[test] + fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() { + // The relationship namespace, and VML's `o:relid`. + assert!(is_rel_attr("w:hyperlink", "r:id")); + assert!(is_rel_attr("v:imagedata", "o:relid")); + // The unqualified alias `El::attr` accepts, on an element that carries a relationship. + assert!(is_rel_attr("w:hyperlink", "id")); + assert!(is_rel_attr("v:imagedata", "id")); + assert!(is_rel_attr("a:blip", "embed")); + assert!(is_rel_attr("dgm:relIds", "dm")); + // An ordinary `id` — the element's own, naming no part — is not a relationship. + assert!(!is_rel_attr("v:shape", "id")); + assert!(!is_rel_attr("pic:cNvPr", "id")); + assert!(!is_rel_attr("wp:docPr", "id")); + assert!(!is_rel_attr("v:background", "id")); + // An attribute that is not a relationship attribute at all. + assert!(!is_rel_attr("w:hyperlink", "anchor")); + assert!(!is_rel_attr("v:imagedata", "title")); + } + #[test] fn utf16_parts_parse_and_odd_ones_never_panic() { let src = r#""#; diff --git a/crates/docx/tests/bidi.rs b/crates/docx/tests/it/bidi.rs similarity index 100% rename from crates/docx/tests/bidi.rs rename to crates/docx/tests/it/bidi.rs diff --git a/crates/docx/tests/bidi_runs.rs b/crates/docx/tests/it/bidi_runs.rs similarity index 100% rename from crates/docx/tests/bidi_runs.rs rename to crates/docx/tests/it/bidi_runs.rs diff --git a/crates/docx/tests/cell_margin_inheritance.rs b/crates/docx/tests/it/cell_margin_inheritance.rs similarity index 100% rename from crates/docx/tests/cell_margin_inheritance.rs rename to crates/docx/tests/it/cell_margin_inheritance.rs diff --git a/crates/docx/tests/char_units.rs b/crates/docx/tests/it/char_units.rs similarity index 100% rename from crates/docx/tests/char_units.rs rename to crates/docx/tests/it/char_units.rs diff --git a/crates/docx/tests/citations.rs b/crates/docx/tests/it/citations.rs similarity index 100% rename from crates/docx/tests/citations.rs rename to crates/docx/tests/it/citations.rs diff --git a/crates/docx/tests/complex_script_inheritance.rs b/crates/docx/tests/it/complex_script_inheritance.rs similarity index 100% rename from crates/docx/tests/complex_script_inheritance.rs rename to crates/docx/tests/it/complex_script_inheritance.rs diff --git a/crates/docx/tests/content_control_save_cost.rs b/crates/docx/tests/it/content_control_save_cost.rs similarity index 100% rename from crates/docx/tests/content_control_save_cost.rs rename to crates/docx/tests/it/content_control_save_cost.rs diff --git a/crates/docx/tests/content_controls.rs b/crates/docx/tests/it/content_controls.rs similarity index 100% rename from crates/docx/tests/content_controls.rs rename to crates/docx/tests/it/content_controls.rs diff --git a/crates/docx/tests/custom_xml.rs b/crates/docx/tests/it/custom_xml.rs similarity index 100% rename from crates/docx/tests/custom_xml.rs rename to crates/docx/tests/it/custom_xml.rs diff --git a/crates/docx/tests/drawing_payload.rs b/crates/docx/tests/it/drawing_payload.rs similarity index 100% rename from crates/docx/tests/drawing_payload.rs rename to crates/docx/tests/it/drawing_payload.rs diff --git a/crates/docx/tests/east_asian_lang.rs b/crates/docx/tests/it/east_asian_lang.rs similarity index 100% rename from crates/docx/tests/east_asian_lang.rs rename to crates/docx/tests/it/east_asian_lang.rs diff --git a/crates/docx/tests/east_asian_layout.rs b/crates/docx/tests/it/east_asian_layout.rs similarity index 100% rename from crates/docx/tests/east_asian_layout.rs rename to crates/docx/tests/it/east_asian_layout.rs diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/it/fixtures.rs similarity index 99% rename from crates/docx/tests/fixtures.rs rename to crates/docx/tests/it/fixtures.rs index ccb5708b3..cf0c588d5 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -1643,6 +1643,39 @@ fn ole_object_round_trips() { assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. }))); } +#[test] +fn an_unqualified_relationship_attribute_round_trips() { + // A producer that left the relationship prefix out: the VML picture names its image with an + // unqualified `id`. The reader resolves it (the alias `El::attr` accepts), the writer keeps + // the attribute and gives it the id the new package holds the image under. + let body = r##""##; + let picture = "\u{89}PNG stand-in"; + let ole = "\u{d0}\u{cf} opaque OLE storage"; + let types = r#""#; + let bytes = docx( + body, + &[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")], + &[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)], + ); + let d = wordcraft_docx::read(&bytes).unwrap(); + let Some(InlineObject::Image { ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) }; + + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + let rels = text(&files, "word/_rels/document.xml.rels"); + // The attribute is still unqualified, and names the image the new package holds. + let [pic_id] = attr_values(&body, "v:imagedata", "id").try_into().unwrap(); + let (kind, target) = rel(&rels, &pic_id); + assert!(kind.ends_with("/image"), "{kind}"); + assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes())); + // The object itself is kept whole, as before. + let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap(); + let (kind, target) = rel(&rels, &ole_id); + assert!(kind.ends_with("/oleObject"), "{kind}"); + assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(ole.as_bytes())); +} + #[test] fn signature_line_round_trips() { // Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile) diff --git a/crates/docx/tests/glossary.rs b/crates/docx/tests/it/glossary.rs similarity index 100% rename from crates/docx/tests/glossary.rs rename to crates/docx/tests/it/glossary.rs diff --git a/crates/docx/tests/legacy_forms.rs b/crates/docx/tests/it/legacy_forms.rs similarity index 100% rename from crates/docx/tests/legacy_forms.rs rename to crates/docx/tests/it/legacy_forms.rs diff --git a/crates/docx/tests/ligatures.rs b/crates/docx/tests/it/ligatures.rs similarity index 100% rename from crates/docx/tests/ligatures.rs rename to crates/docx/tests/it/ligatures.rs diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs new file mode 100644 index 000000000..80475e3b0 --- /dev/null +++ b/crates/docx/tests/it/main.rs @@ -0,0 +1,32 @@ +mod bidi; +mod bidi_runs; +mod cell_margin_inheritance; +mod char_units; +mod citations; +mod complex_script_inheritance; +mod content_control_save_cost; +mod content_controls; +mod custom_xml; +mod drawing_payload; +mod east_asian_lang; +mod east_asian_layout; +mod fixtures; +mod glossary; +mod legacy_forms; +mod ligatures; +mod malformed; +mod passthrough; +mod positional_tabs; +mod privacy; +mod roundtrip; +mod row_property_exceptions; +mod section_form_protection; +mod section_page_number_type; +mod section_text_direction; +mod section_valign; +mod settings_merge; +mod text_boxes; +mod toc_ranges; +mod vml_shapes; +mod word_xml; +mod wordml2003_write; diff --git a/crates/docx/tests/malformed.rs b/crates/docx/tests/it/malformed.rs similarity index 100% rename from crates/docx/tests/malformed.rs rename to crates/docx/tests/it/malformed.rs diff --git a/crates/docx/tests/passthrough.rs b/crates/docx/tests/it/passthrough.rs similarity index 100% rename from crates/docx/tests/passthrough.rs rename to crates/docx/tests/it/passthrough.rs diff --git a/crates/docx/tests/positional_tabs.rs b/crates/docx/tests/it/positional_tabs.rs similarity index 100% rename from crates/docx/tests/positional_tabs.rs rename to crates/docx/tests/it/positional_tabs.rs diff --git a/crates/docx/tests/privacy.rs b/crates/docx/tests/it/privacy.rs similarity index 100% rename from crates/docx/tests/privacy.rs rename to crates/docx/tests/it/privacy.rs diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs similarity index 100% rename from crates/docx/tests/roundtrip.rs rename to crates/docx/tests/it/roundtrip.rs diff --git a/crates/docx/tests/row_property_exceptions.rs b/crates/docx/tests/it/row_property_exceptions.rs similarity index 100% rename from crates/docx/tests/row_property_exceptions.rs rename to crates/docx/tests/it/row_property_exceptions.rs diff --git a/crates/docx/tests/section_form_protection.rs b/crates/docx/tests/it/section_form_protection.rs similarity index 100% rename from crates/docx/tests/section_form_protection.rs rename to crates/docx/tests/it/section_form_protection.rs diff --git a/crates/docx/tests/section_page_number_type.rs b/crates/docx/tests/it/section_page_number_type.rs similarity index 100% rename from crates/docx/tests/section_page_number_type.rs rename to crates/docx/tests/it/section_page_number_type.rs diff --git a/crates/docx/tests/section_text_direction.rs b/crates/docx/tests/it/section_text_direction.rs similarity index 100% rename from crates/docx/tests/section_text_direction.rs rename to crates/docx/tests/it/section_text_direction.rs diff --git a/crates/docx/tests/section_valign.rs b/crates/docx/tests/it/section_valign.rs similarity index 100% rename from crates/docx/tests/section_valign.rs rename to crates/docx/tests/it/section_valign.rs diff --git a/crates/docx/tests/settings_merge.rs b/crates/docx/tests/it/settings_merge.rs similarity index 100% rename from crates/docx/tests/settings_merge.rs rename to crates/docx/tests/it/settings_merge.rs diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/it/text_boxes.rs similarity index 96% rename from crates/docx/tests/text_boxes.rs rename to crates/docx/tests/it/text_boxes.rs index dda7f614a..0040e942c 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/it/text_boxes.rs @@ -234,3 +234,16 @@ fn picture_in_box_keeps_its_frame() { let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0)); } + +#[test] +fn story_shared_by_many_shapes_is_written_once() { + let mut d = Document::from_text("x"); + let text = "abcdefghij".repeat(5_000); + let o = text_box(&mut d, &text, TextBody::default()); + for _ in 0..500 { + d.insert_object(&wordcraft_doc::Pos::body(0, 0), o.clone(), &Default::default()).unwrap(); + } + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.len() < 2_000_000, "document.xml is {} bytes", xml.len()); + assert_eq!(xml.matches(text.as_str()).count(), 1); +} diff --git a/crates/docx/tests/toc_ranges.rs b/crates/docx/tests/it/toc_ranges.rs similarity index 100% rename from crates/docx/tests/toc_ranges.rs rename to crates/docx/tests/it/toc_ranges.rs diff --git a/crates/docx/tests/vml_shapes.rs b/crates/docx/tests/it/vml_shapes.rs similarity index 100% rename from crates/docx/tests/vml_shapes.rs rename to crates/docx/tests/it/vml_shapes.rs diff --git a/crates/docx/tests/word_xml.rs b/crates/docx/tests/it/word_xml.rs similarity index 100% rename from crates/docx/tests/word_xml.rs rename to crates/docx/tests/it/word_xml.rs diff --git a/crates/docx/tests/wordml2003_write.rs b/crates/docx/tests/it/wordml2003_write.rs similarity index 76% rename from crates/docx/tests/wordml2003_write.rs rename to crates/docx/tests/it/wordml2003_write.rs index 4ccb3aadf..039fd65f1 100644 --- a/crates/docx/tests/wordml2003_write.rs +++ b/crates/docx/tests/it/wordml2003_write.rs @@ -179,3 +179,51 @@ fn big_and_deep_documents_write() { let _ = read_xml(&xml); } } + +#[test] +fn shared_header_and_repeated_note_are_not_inlined_per_reference() { + // #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500 + // times must not be copied into the output once per section or reference. + let mut d = Document::new(); + let big = format!("HDRMARK {}", "h".repeat(10_000)); + let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&big, CharProps::default()))]); + let note = format!("NOTEMARK {}", "n".repeat(10_000)); + let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text(¬e, CharProps::default()))]); + d.last_section.headers.default = Some(head); + let mut blocks = Vec::new(); + for i in 0..1000 { + let mut p = Paragraph::with_text("x", CharProps::default()); + if i < 500 { + p.insert_object(1, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap(); + } + p.section = Some(Box::new(d.last_section.clone())); + blocks.push(para_block(p)); + } + d.body = blocks; + let xml = write_wordml2003(&d).expect("write"); + assert!(read_xml(&xml).is_ok()); + let text = String::from_utf8(xml).expect("utf-8"); + let hdr = text.matches("HDRMARK").count(); + let notes = text.matches("NOTEMARK").count(); + assert!(text.len() < 2_000_000 && hdr <= 3 && notes <= 3, "output {} bytes, header x{hdr}, note x{notes}", text.len()); +} + +#[test] +fn headers_past_the_inlined_byte_budget_fail_the_export() { + // #3652: two ~1 MB headers alternating over 80 sections must each be written where they + // change; past the byte budget the export fails rather than growing or dropping a header. + let mut d = Document::new(); + let a = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"a".repeat(1_000_000), CharProps::default()))]); + let b = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"b".repeat(1_000_000), CharProps::default()))]); + let mut blocks = Vec::new(); + for i in 0..80 { + let mut p = Paragraph::with_text("x", CharProps::default()); + let mut s = d.last_section.clone(); + s.headers.default = Some(if i % 2 == 0 { a } else { b }); + p.section = Some(Box::new(s)); + blocks.push(para_block(p)); + } + d.body = blocks; + let out = write_wordml2003(&d); + assert!(matches!(out, Err(wordcraft_docx::DocxError::Limit(_))), "expected a limit error, got {:?}", out.map(|x| x.len())); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index fd23166fc..896fa9e28 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -279,9 +279,13 @@ Mailings|Write & Insert Fields|Match Fields|mailings.matchFields Mailings|Preview Results|Preview Results|mailings.preview Mailings|Preview Results|Next Record|mailings.next Mailings|Preview Results|Previous Record|mailings.previous +Mailings|Preview Results|First Record|mailings.first +Mailings|Preview Results|Last Record|mailings.last +Mailings|Preview Results|Go to Record|mailings.goToRecord Mailings|Preview Results|Find Recipient|mailings.findRecipient Mailings|Preview Results|Check for Errors|mailings.checkErrors Mailings|Finish|Finish & Merge|mailings.finish +Mailings|Finish|Print Documents|mailings.printDocuments Review|Proofing|Spelling & Grammar|review.spelling Review|Proofing|Thesaurus|review.thesaurus Review|Proofing|Word Count|review.wordCount diff --git a/crates/engine/src/cmd/chart.rs b/crates/engine/src/cmd/chart.rs index 50321fbb0..7fd4aba1c 100644 --- a/crates/engine/src/cmd/chart.rs +++ b/crates/engine/src/cmd/chart.rs @@ -145,7 +145,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { let kind = chart_type(v)?; // No type and no data: Insert › Chart's picker, as the ribbon button does. if kind.is_none() && v.get("data").is_none() { - s.ui_requests.push(json!({"open": "insertChart"})); + s.push_ui_request(json!({"open": "insertChart"})); return sel_result(s); } let kind = kind.unwrap_or_default(); diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index b646bd459..6c2cadd41 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -529,6 +529,7 @@ fn auto_tag(doc: &Document, src: &Source) -> String { } /// The author of a source for matching untagged entries: the editor when there is no author. +#[cfg(not(target_arch = "wasm32"))] fn by_author(x: &Source) -> &str { if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() } } diff --git a/crates/engine/src/cmd/connectors.rs b/crates/engine/src/cmd/connectors.rs index adadd0a7c..753ec88d8 100644 --- a/crates/engine/src/cmd/connectors.rs +++ b/crates/engine/src/cmd/connectors.rs @@ -77,8 +77,8 @@ pub fn reroute(s: &mut Session) -> bool { let mut moved = false; for pos in groups { if let Ok(para) = s.doc.para_mut(pos.story, &pos.path) - && let Some(InlineObject::Group { children, .. }) = para.object_at_mut(pos.off) - && wordcraft_doc::connector::reroute_group(children) + && let Some(group @ InlineObject::Group { .. }) = para.object_at_mut(pos.off) + && wordcraft_doc::connector::reroute_group(group) { para.touch(); moved = true; @@ -290,3 +290,103 @@ fn connect(s: &mut Session, v: &Value) -> CmdResult { let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned(); Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) } + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::connector::ShapeExtra; + use wordcraft_doc::para::{Float, GroupChild, ShapeKind}; + + fn shape(kind: ShapeKind, extra: ShapeExtra, float: Float) -> InlineObject { + InlineObject::Shape { + kind, + w: 40.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + freeform: None, + effects: Default::default(), + extra, + } + } + + fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) { + let InlineObject::Group { w, h, float, .. } = group else { panic!("group") }; + let rects = group.group_rects(0.0, 0.0, *w, *h); + let ([x, y, w, h], obj) = rects[1]; + let own = obj.frame().unwrap().2.spin(); + let outer = float.spin(); + let turn = |(x, y)| { + let (x, y) = own.apply(rects[1].0[0] + w / 2.0, rects[1].0[1] + h / 2.0, x, y); + let InlineObject::Group { w, h, .. } = group else { panic!("group") }; + outer.apply(*w / 2.0, *h / 2.0, x, y) + }; + (turn((x, y)), turn((x + w, y + h))) + } + + #[test] + fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() { + for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] { + for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] { + for rot in [30.0, 90.0] { + for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] { + for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (free, attached) } else { (attached, free) }; + let group = InlineObject::Group { + w, + h, + ch_w: 200.0, + ch_h: 300.0, + float: Float { rot: 25.0, flip_h: true, ..Default::default() }, + canvas: None, + children: vec![ + GroupChild { + x: 100.0, + y: 200.0, + obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()), + }, + GroupChild { + x: 10.0, + y: 30.0, + obj: shape( + kind, + ShapeExtra { start, end, ..Default::default() }, + Float { rot, flip_h, flip_v, ..Default::default() }, + ), + }, + ], + }; + // Rendering scales each child rectangle before applying its own spin. + let before = visual_ends(&group); + let expected = if free_start { before.0 } else { before.1 }; + let mut s = Session::new(wordcraft_doc::Document::new()); + let pos = s.doc.start_of(StoryRef::Body); + s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap(); + assert!(reroute(&mut s)); + let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap(); + let after = visual_ends(group); + let actual = if free_start { after.0 } else { after.1 }; + assert!( + (actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001, + "{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}" + ); + let InlineObject::Group { children, .. } = group else { panic!("group") }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") }; + let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates"); + assert_eq!(float.rot, 0.0); + assert!(!reroute(&mut s), "rerouting must settle"); + } + } + } + } + } + } + } +} diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 24b2788cc..6ad46d76d 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -112,9 +112,11 @@ fn symbol_param(v: &Value, k: &str) -> Option { /// A date `YYYY-MM-DD` (a full ISO date-time is fine too): (year, month, day), checked. pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> { let t = t.trim(); - let y: i64 = t.get(0..4)?.parse().ok()?; - let m: u32 = t.get(5..7)?.parse().ok()?; - let d: u32 = t.get(8..10)?.parse().ok()?; + // Digits only: `str::parse` would also accept a leading '+'. + let field = |r: std::ops::Range| t.get(r).filter(|s| s.bytes().all(|b| b.is_ascii_digit())); + let y: i64 = field(0..4)?.parse().ok()?; + let m: u32 = field(5..7)?.parse().ok()?; + let d: u32 = field(8..10)?.parse().ok()?; if t.get(4..5) != Some("-") || t.get(7..8) != Some("-") || !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m) { return None; @@ -761,6 +763,15 @@ mod tests { assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1)); } + #[test] + fn parse_date_rejects_signs_and_non_digits() { + for bad in ["2024-+1-+5", "+123-01-01", "2024-01-+5", "2024-+1-05", "-024-01-01"] { + assert_eq!(parse_date(bad), None, "{bad}"); + } + assert_eq!(parse_date("2024-01-05"), Some((2024, 1, 5))); + assert_eq!(parse_date("2024-01-05T10:00:00"), Some((2024, 1, 5))); + } + #[test] fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() { // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8c890afd0..0dfb5e149 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { t @ ("find" | "replace" | "goto") => t, other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))), }; - s.ui_requests.push(json!({"open": tab})); + s.push_ui_request(json!({"open": tab})); Ok(json!({"open": tab})) }) // Word for Mac's own key is ⌃H. @@ -603,7 +603,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult { // Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the // Find and Replace dialog is Advanced Find. s.view.nav_pane = true; - s.ui_requests.push(json!({"focus": "navSearch"})); + s.push_ui_request(json!({"focus": "navSearch"})); return Ok(json!({"count": s.find.results.len()})); } let story = s.sel.focus.story; @@ -624,7 +624,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult { /// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read. fn advanced_find(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "text").is_none() && v.get("highlight").is_none() { - s.ui_requests.push(json!({"open": "find"})); + s.push_ui_request(json!({"open": "find"})); return Ok(json!({"count": s.find.results.len()})); } read_opts(s, v); @@ -748,7 +748,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { s.sel = Selection::caret(t); s.page_hint = idx; } - s.ui_requests.push(json!({"scrollTo": "caret"})); + s.push_ui_request(json!({"scrollTo": "caret"})); return sel_result(s); } if let Some(name) = p::str(v, "bookmark") { @@ -769,7 +769,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { } return sel_result(s); } - s.ui_requests.push(json!({"open": "goto"})); + s.push_ui_request(json!({"open": "goto"})); sel_result(s) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7b21994e2..cf59c833e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,16 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc"; pub const MAX_AUTHOR_CHARS: usize = 255; -/// Sets the user name that new tracked changes and comments are recorded under. The name is -/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to -/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author. -pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { - let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); +/// The user name as it is stored: trimmed, stripped of control characters (they can't go into +/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always +/// carry an author. +pub fn normalize_author(name: &str) -> Result { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); let name = name.trim_end(); if name.is_empty() { return Err(CmdError::Params("`name` must not be blank".into())); } - s.author = name.to_string(); + Ok(name.to_string()) +} + +/// Sets the user name that new tracked changes and comments are recorded under. The name is +/// normalised by [`normalize_author`]. +/// A protected document refuses it: the name is the identity its exceptions are checked against. +pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { + super::protect::check_user_name(s)?; + s.author = normalize_author(p::req_str(v, "name")?)?; let mut r = sel_result(s)?; if let Some(o) = r.as_object_mut() { o.insert("name".into(), json!(s.author)); @@ -93,7 +101,7 @@ pub fn specs() -> Vec { // No path (the command search, say): the app asks where to save, as File › Export // does, instead of failing on the required parameter (#2313). let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "exportPdf"})); + s.push_ui_request(json!({"open": "exportPdf"})); return Ok(json!({"exported": false})); }; let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; @@ -119,13 +127,13 @@ pub fn specs() -> Vec { ) .pure(), CommandSpec::new("file.print", "Print", "File", |s, _| { - s.ui_requests.push(json!({"open": "print"})); + s.push_ui_request(json!({"open": "print"})); sel_result(s) }) .key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I") .pure(), CommandSpec::new("file.close", "Close", "File", |s, _| { - s.ui_requests.push(json!({"close": true})); + s.push_ui_request(json!({"close": true})); sel_result(s) }) .key("Mod+W / Mod+F4") @@ -219,7 +227,7 @@ fn normal_document(s: &Session) -> Document { /// Open a document, using the saved proofing default only for plain text and Markdown. fn open(s: &mut Session, v: &Value) -> CmdResult { let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "openFile"})); + s.push_ui_request(json!({"open": "openFile"})); return sel_result(s); }; let password = p::str(v, "password"); @@ -282,7 +290,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) { Some(p) => p, None => { - s.ui_requests.push(json!({"open": "saveAs"})); + s.push_ui_request(json!({"open": "saveAs"})); return Ok(json!({"saved": false})); } }; @@ -349,23 +357,29 @@ fn is_word_2003_file(_path: &std::path::Path) -> bool { fn save_as(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "path").is_none() { - s.ui_requests.push(json!({"open": "saveAs"})); + s.push_ui_request(json!({"open": "saveAs"})); return Ok(json!({"saved": false})); } save(s, v) } /// How a copy is written: Print Markup from the command's `markup`, else the File › Print -/// setting (`file.printMarkup`); comments as Track Changes Options show them. +/// setting (`file.printMarkup`); comments as Track Changes Options show them; digits as the +/// Numeral setting shows them on screen. pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions { let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup); - crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) } + crate::io::ExportOptions { + markup, + comments: markup && s.prefs.markup.comments, + word2003: p::str(v, "format") == Some(FORMAT_WORD_2003), + numeral: s.view.numeral.resolve(crate::session::system_locale()), + } } fn export_png(s: &mut Session, v: &Value) -> CmdResult { // No path: the app asks where to save, as file.exportPdf does (#2313). let Some(path) = p::str(v, "path") else { - s.ui_requests.push(json!({"open": "exportPng"})); + s.push_ui_request(json!({"open": "exportPng"})); return Ok(json!({"exported": false})); }; let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1; @@ -376,6 +390,9 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult { // the on-screen cue while editing the body, not part of the page. let mut opts = wordcraft_render::RenderOptions::default(); opts.display.dim_header = false; + if !path.to_ascii_lowercase().ends_with(".png") { + return Err(CmdError::Params("path must end in .png".into())); + } let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts); let png = img.to_png(); crate::io::write_file(path, &png).map_err(CmdError::Failed)?; diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index 0460c2274..dfb7a4816 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { } fn load_system(s: &mut Session, _v: &Value) -> CmdResult { - s.ui_requests.push(json!({"open": "loadSystemFonts"})); + s.push_ui_request(json!({"open": "loadSystemFonts"})); Ok(json!({"pending": true})) } @@ -68,7 +68,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult { } } else { // From the ribbon: the UI shows a file picker and runs this again with the file. - s.ui_requests.push(json!({"open": "addFont"})); + s.push_ui_request(json!({"open": "addFont"})); return Ok(json!({"pending": true})); }; let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index bc2d4fdef..1d0ee5a7d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure}; use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign}; use wordcraft_doc::resolve::ResolvedChar; -use wordcraft_doc::{Pos, RevisionKind}; +use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -139,7 +139,7 @@ pub fn specs() -> Vec { CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#), CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#), CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| { - s.ui_requests.push(json!({"open": "font"})); + s.push_ui_request(json!({"open": "font"})); sel_result(s) }) .key("Mod+D") @@ -170,6 +170,14 @@ fn target(s: &Session) -> Option<(Pos, Pos)> { None } +/// A text box selected as an object (its frame, not text in it): all its text, which Word's Font +/// group formats then, instead of the box's anchor in the text around it. +fn selected_text_box(s: &Session) -> Option<(Pos, Pos)> { + let (_, InlineObject::Shape { story: Some(id), .. }) = super::objects::object_selection(s)? else { return None }; + let story = StoryRef::Part(*id); + s.doc.parts.get(id).filter(|p| p.kind == PartKind::TextBox).map(|_| (s.doc.start_of(story), s.doc.end_of(story))) +} + /// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph: /// an empty one's mark takes the formatting. fn column_rows(s: &Session) -> Option> { @@ -198,7 +206,7 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { } return sel_result(s); } - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => { s.doc.format_range(&a, &b, f)?; } @@ -242,7 +250,7 @@ fn resolved(s: &Session) -> Vec { return out; } } - let Some((a, b)) = target(s) else { + let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) else { let f = &s.sel.focus; let style = s.doc.para_at(f).and_then(|p| p.props.style.clone()); out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); @@ -262,8 +270,11 @@ fn resolved(s: &Session) -> Vec { } } if out.is_empty() { - let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone()); - out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); + let p = s.doc.para_at(&a); + let style = p.and_then(|p| p.props.style.clone()); + // An empty selected text box has only its paragraph mark, which is what gets formatted. + let empty_box = selected_text_box(s).and(p).map(|p| &p.mark); + out.push(s.doc.styles.resolve_char(style.as_deref(), empty_box.unwrap_or(&s.typing_props()))); } out } @@ -393,9 +404,16 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult { apply(s, &|x| x.highlight = Some(h)) } +/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle +/// without one. Works on the selection, the word at the caret, or a selected text box's text. fn change_case(s: &mut Session, v: &Value) -> CmdResult { - let (a, b) = s.sel.ordered(); - let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) }; + let (a, b) = match selected_text_box(s) { + Some(all) => all, + None => { + let (a, b) = s.sel.ordered(); + if a == b { target(s).unwrap_or((a, b)) } else { (a, b) } + } + }; if a == b { return sel_result(s); } @@ -630,8 +648,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec { out } +/// `format.clear` (Clear All Formatting): direct character formatting off and the paragraphs back +/// to Normal, for the selection or a selected text box's text; at a caret, for what's typed next. fn clear(s: &mut Session, _: &Value) -> CmdResult { - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => { s.doc.format_range(&a, &b, &|c| *c = c.cleared())?; // Clear Formatting also resets the paragraph to Normal. @@ -652,7 +672,7 @@ fn clear(s: &mut Session, _: &Value) -> CmdResult { /// Remove direct character formatting, keeping the paragraph and any character style. fn reset_char(s: &mut Session, _: &Value) -> CmdResult { - match target(s) { + match selected_text_box(s).or_else(|| target(s)) { Some((a, b)) => s.doc.format_range(&a, &b, &|c| *c = CharProps { style: c.style.clone(), ..c.cleared() })?, None => s.pending = Some(CharProps::default()), } @@ -684,7 +704,9 @@ pub fn state(s: &Session) -> Value { let v0 = f(r); if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null } }; - let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into()); + // A selected text box shows its own (first) paragraph's style, like its text's formatting. + let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a); + let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into()); let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone()); json!({ "font": same(&|r| json!(r.font)), diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index a381f3bda..2493d07f7 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -221,7 +221,7 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { } else if let Some(path) = p::str(v, "path") { crate::io::read_file(path).map_err(CmdError::Failed)? } else { - s.ui_requests.push(json!({"open": "insertPicture"})); + s.push_ui_request(json!({"open": "insertPicture"})); return sel_result(s); }; place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) @@ -295,7 +295,7 @@ fn icon(s: &mut Session, v: &Value) -> CmdResult { .iter() .map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords})) .collect(); - s.ui_requests.push(json!({"open": "icons"})); + s.push_ui_request(json!({"open": "icons"})); return Ok(json!({"icons": icons})); }; let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?; @@ -441,7 +441,7 @@ fn link(s: &mut Session, v: &Value) -> CmdResult { None => match place_target(s, v)? { Some(t) => t, None => { - s.ui_requests.push(json!({"open": "link"})); + s.push_ui_request(json!({"open": "link"})); return sel_result(s); } }, diff --git a/crates/engine/src/cmd/inspector.rs b/crates/engine/src/cmd/inspector.rs index 8d304c9a3..30cabbab5 100644 --- a/crates/engine/src/cmd/inspector.rs +++ b/crates/engine/src/cmd/inspector.rs @@ -122,7 +122,13 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult { } "paragraphFormatting" => { s.doc.format_paragraphs(&a, &b, &|p| { - *p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() }; + *p = ParaProps { + style: p.style.clone(), + numbering: p.numbering, + num_change: p.num_change.take(), + fmt_change: p.fmt_change.take(), + ..Default::default() + }; })?; } "characterStyle" => return super::format::apply(s, &|c| c.style = None), @@ -136,6 +142,7 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult { lang: c.lang.clone(), no_proof: c.no_proof, rtl: c.rtl, + fmt_change: c.fmt_change.take(), ..Default::default() }; }); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index eebbc162d..2d6e807d0 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -439,6 +439,15 @@ pub fn specs() -> Vec { .when(has_recipients), CommandSpec::new("mailings.next", "Next Record", "Mailings › Preview Results", |s, _| step(s, 1)).when(has_recipients), CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients), + CommandSpec::new("mailings.first", "First Record", "Mailings › Preview Results", |s, _| go_to(s, 0)).when(has_recipients), + CommandSpec::new("mailings.last", "Last Record", "Mailings › Preview Results", |s, _| go_to(s, i64::MAX)).when(has_recipients), + CommandSpec::new("mailings.goToRecord", "Go to Record", "Mailings › Preview Results", |s, v| { + let n = v.get("record").and_then(|r| r.as_i64().or_else(|| r.as_str().and_then(|t| t.trim().parse().ok()))); + let n = n.ok_or_else(|| CmdError::Params("`record` must be a record number (1 = the first)".into()))?; + go_to(s, n.saturating_sub(1)) + }) + .params(r#"{"record": n (1 = the first; past either end goes to the first or last record)}"#) + .when(has_recipients), CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| { let q = p::req_str(v, "text")?.to_lowercase(); let idx = s @@ -464,9 +473,21 @@ pub fn specs() -> Vec { }) .pure(), CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish) - .params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#) + .params(r#"{"path"?: string (save the merged document), "records"?: "all|current" (current: the previewed record; default all, or from/to), "from"?: n, "to"?: n (record numbers, 1 = the first)}"#) .key(super::non_mac("Alt+Shift+N")) .when(has_recipients), + CommandSpec::new("mailings.printDocuments", "Print Documents", "Mailings › Finish › Finish & Merge", |s, v| { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() { + o.remove("path"); + } + let r = finish(s, &v)?; + s.push_ui_request(json!({"open": "print"})); + Ok(r) + }) + .params(r#"{"records"?: "all|current", "from"?: n, "to"?: n} (merges into a new document, as Finish & Merge does, and opens Print for it)"#) + .key(super::non_mac("Alt+Shift+M")) + .when(has_recipients), CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params( r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#, ), @@ -724,8 +745,13 @@ fn header_split(mut all: Vec>, headers: bool) -> Result<(Vec } fn step(s: &mut Session, d: i64) -> CmdResult { + go_to(s, (s.merge.record as i64).saturating_add(d)) +} + +/// Preview record `idx` (0-based), clamped to the list. +fn go_to(s: &mut Session, idx: i64) -> CmdResult { let n = s.merge.rows.len().max(1) as i64; - s.merge.record = (s.merge.record as i64 + d).clamp(0, n - 1) as usize; + s.merge.record = idx.clamp(0, n - 1) as usize; s.merge.preview = true; refresh(s); Ok(json!({"record": s.merge.record + 1, "of": n})) @@ -933,6 +959,23 @@ pub fn refresh(s: &mut Session) { s.touch(); } +/// The records Finish & Merge and Print Documents take (0-based, end exclusive): `records` +/// all or current, or `from` and `to` by record number. A range with none is an error. +pub fn merge_range(s: &Session, v: &Value) -> Result<(usize, usize), CmdError> { + let n = s.merge.rows.len(); + let (from, to) = match p::str(v, "records").map(str::trim) { + Some("current") => (s.merge.record.min(n.saturating_sub(1)), s.merge.record.saturating_add(1).min(n)), + None | Some("all") => { + ((p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1), (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize) + } + Some(_) => return Err(CmdError::Params("`records` must be all or current".into())), + }; + if from >= to { + return Err(CmdError::Failed(format!("no records from {} to {to} (the list has {n})", from.saturating_add(1)))); + } + Ok((from, to)) +} + /// Most merged documents' worth of body blocks Finish & Merge builds. const MAX_MERGED_BLOCKS: usize = 200_000; @@ -942,9 +985,7 @@ const MAX_MERGED_BLOCKS: usize = 200_000; /// are merged too: with merge fields in them, each letter becomes its own section with its own /// header/footer copies. fn finish(s: &mut Session, v: &Value) -> CmdResult { - let n = s.merge.rows.len(); - let from = (p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1); - let to = (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize; + let (from, to) = merge_range(s, v)?; let directory = s.merge.kind == "directory"; let hf: Vec = header_footer_ids(&s.doc).into_iter().filter(|id| s.doc.story(StoryRef::Part(*id)).is_some_and(|b| has_merge_fields(b, 0))).collect(); @@ -1580,6 +1621,65 @@ mod tests { assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha / Beta\nGamma / "); } + /// #3028: First, Last and Go to Record move the preview; Finish & Merge and Print Documents + /// take all records, the current one, or a range by record number. + #[test] + fn record_navigation_and_merge_ranges() { + let names = ["Alpha", "Beta", "Gamma", "Delta"]; + let fresh = || { + let mut s = Session::new(Document::new()); + s.run("mailings.recipients", &json!({"rows": names.map(|n| json!({"Name": n}))})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + s.run("mailings.insertField", &json!({"field": "Name"})).unwrap(); + s + }; + let mut s = fresh(); + let body = |s: &Session| s.doc.plain_text(StoryRef::Body); + assert_eq!(s.run("mailings.last", &json!({})).unwrap(), json!({"record": 4, "of": 4})); + assert_eq!(body(&s), "Delta"); + assert_eq!(s.run("mailings.first", &json!({})).unwrap()["record"], 1); + assert_eq!(body(&s), "Alpha"); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": 3})).unwrap()["record"], 3); + assert_eq!(body(&s), "Gamma"); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": " 2 "})).unwrap()["record"], 2); + // Past either end: the last or first record, as the step buttons do. + assert_eq!(s.run("mailings.goToRecord", &json!({"record": 1141})).unwrap()["record"], 4); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": -5})).unwrap()["record"], 1); + for bad in [json!({}), json!({"record": "two"}), json!({"record": 2.5})] { + assert!(matches!(s.run("mailings.goToRecord", &bad), Err(CmdError::Params(_))), "{bad}"); + } + + s.run("mailings.goToRecord", &json!({"record": 3})).unwrap(); + assert_eq!(s.run("mailings.finish", &json!({"records": "current"})).unwrap()["records"], 1); + assert_eq!(body(&s), "Gamma"); + let mut s = fresh(); + assert_eq!(s.run("mailings.finish", &json!({"from": 2, "to": 3})).unwrap()["records"], 2); + assert_eq!(body(&s), "Beta\nGamma"); + let mut s = fresh(); + assert_eq!(s.run("mailings.finish", &json!({"records": "all", "from": 3, "to": 99})).unwrap()["records"], 2); + assert_eq!(body(&s), "Gamma\nDelta"); + // An empty range is refused and the document is kept, not replaced by an empty one. + let mut s = fresh(); + let before = body(&s); + for bad in [json!({"from": 3, "to": 2}), json!({"from": 5}), json!({"records": "some"})] { + assert!(s.run("mailings.finish", &bad).is_err(), "{bad}"); + assert_eq!(body(&s), before, "{bad}"); + } + let e = s.run("mailings.finish", &json!({"from": 5})).unwrap_err().to_string(); + assert_eq!(e, "no records from 5 to 4 (the list has 4)", "a plain message for the dialog"); + + // Print Documents merges the same way, then asks the interface for Print. + let mut s = fresh(); + s.ui_requests.clear(); + assert_eq!(s.run("mailings.printDocuments", &json!({"from": 4, "path": "ignored.docx"})).unwrap()["records"], 1); + assert_eq!(body(&s), "Delta"); + assert!(s.ui_requests.contains(&json!({"open": "print"}))); + let mut s = fresh(); + s.ui_requests.clear(); + assert!(s.run("mailings.printDocuments", &json!({"from": 9})).is_err()); + assert!(s.ui_requests.is_empty(), "nothing to print, no Print dialog"); + } + /// #429: an IF rule shows its true / false text in Preview Results, as Finish & Merge does. #[test] fn if_rule_previews_its_result() { diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index febd55660..1bdc2be14 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -130,7 +130,8 @@ pub fn specs() -> Vec { } if sharpen > 0.0 { img = img.unsharpen(1.0 + sharpen / 50.0, 2); - } else if sharpen < 0.0 { + } else if sharpen < 0.0 && (-sharpen / 25.0).is_normal() { + // `blur` asserts on a subnormal width. img = img.blur(-sharpen / 25.0); } img @@ -1563,6 +1564,11 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { if shown.is_empty() { return Err(CmdError::Failed("nothing to group".into())); } + // `shown` already has the members of any picked group merged in, so this counts the + // flattened members. Readers stop at the cap and would drop the rest. + if shown.len() > wordcraft_doc::para::MAX_GROUP_CHILDREN { + return Err(CmdError::Disabled(format!("too many objects to group (at most {})", wordcraft_doc::para::MAX_GROUP_CHILDREN))); + } let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET)); // Each member keeps its turn: the group itself starts unturned. let children: Vec = shown @@ -2010,6 +2016,17 @@ mod tests { assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err()); } + /// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it. + #[test] + fn a_tiny_sharpen_value_does_not_panic() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data})).unwrap(); + for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] { + s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}")); + } + } + /// Transparency is absolute from the original pixels: 80% then 0% is opaque again /// (it used to stay faded), and repeating a value is idempotent. Corrections and color /// re-set the same way: brightness 0 and saturation 100 restore the original. @@ -2232,6 +2249,50 @@ mod tests { assert_eq!(all_objects(&s).len(), 2); } + /// Select the objects at `at` in the list of all objects (the first as the selection, the rest added). + fn pick_objects(s: &mut Session, at: &[usize]) { + let list = all_objects(s); + let first = &list[at[0]].0; + s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + wordcraft_doc::para::OBJ.len_utf8(), ..first.clone() } }; + s.also_selected = at[1..].iter().map(|i| list[*i].0.clone()).collect(); + } + + #[test] + fn group_refuses_more_than_the_child_cap() { + let cap = wordcraft_doc::para::MAX_GROUP_CHILDREN; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "one"})).unwrap(); + s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); + for _ in 0..=cap { + s.run("insert.shape", &json!({"kind": "rectangle", "width": 10, "height": 10})).unwrap(); + s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 100})).unwrap(); + s.run("select.collapse", &json!({"end": true})).unwrap(); + } + let n = all_objects(&s).len(); + assert_eq!(n, cap + 1); + + // Flat: every shape picked. + pick_objects(&mut s, &(0..n).collect::>()); + assert_eq!(picked_objects(&s).len(), n); + let before = s.doc.clone(); + assert!(s.run("arrange.group", &json!({})).is_err()); + assert_eq!(s.doc, before); + + // Nested: a group of cap - 1 and a group of 2; grouping those two would hold cap + 1 members. + pick_objects(&mut s, &(0..cap - 1).collect::>()); + s.run("arrange.group", &json!({})).unwrap(); + let rest = all_objects(&s).len(); + assert_eq!(rest, 3); + pick_objects(&mut s, &[1, 2]); + s.run("arrange.group", &json!({})).unwrap(); + assert_eq!(all_objects(&s).len(), 2); + pick_objects(&mut s, &[0, 1]); + assert_eq!(picked_objects(&s).len(), 2); + let before = s.doc.clone(); + assert!(s.run("arrange.group", &json!({})).is_err()); + assert_eq!(s.doc, before); + } + /// Page 1 as exported (`file.exportPng` at scale 1), its pixels. fn page_pixels(s: &Session) -> Vec { let l = s.export_layout(); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index a2d2720ba..74777db99 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -201,7 +201,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { // Marks, hidden text and proofing change what is laid out. s.relayout(); } else { - s.ui_requests.push(json!({"open": "options", "pane": pane})); + s.push_ui_request(json!({"open": "options", "pane": pane})); } let mut r = current(s); if let Some(o) = r.as_object_mut() { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 5d8055a72..ff1d113c9 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { validate_section(&mut new)?; return with_sect(s, |x| *x = new.clone()); } - s.ui_requests.push(json!({"open": "pageSetup"})); + s.push_ui_request(json!({"open": "pageSetup"})); Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) }) .params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#), diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 3ce6d739b..c87b15200 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -199,7 +199,7 @@ pub fn specs() -> Vec { r#"{"props": ParaProps, "onPage"?: {"align"?: "left|center|right|justify|distribute", "left"?: pt, "right"?: pt} (alignment and indents as seen on the page: each paragraph's own direction, after `props`, says which edge is its start)}"#, ), CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| { - s.ui_requests.push(json!({"open": "paragraph"})); + s.push_ui_request(json!({"open": "paragraph"})); sel_result(s) }) .pure(), @@ -213,7 +213,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)"))); } let list = manage_list(s, sort == "alphabetical"); - s.ui_requests.push(json!({"open": "manageStyles"})); + s.push_ui_request(json!({"open": "manageStyles"})); Ok(list) }) .params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#) diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index b4353ac0b..5327e8e02 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -106,7 +106,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { req[k] = json!(t); } } - s.ui_requests.push(req); + // Only the newest dialog request matters; older ones would pin their payloads. + s.ui_requests.retain(|r| r["open"] != "pasteSpecial"); + s.push_ui_request(req); return Ok(json!({"formats": formats_json(&ids)})); }; let fmt = match fmt.to_ascii_lowercase().as_str() { diff --git a/crates/engine/src/cmd/phonetic.rs b/crates/engine/src/cmd/phonetic.rs index 50262aa50..9c4c900a8 100644 --- a/crates/engine/src/cmd/phonetic.rs +++ b/crates/engine/src/cmd/phonetic.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { /// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`). fn phonetic(s: &mut Session, v: &Value) -> CmdResult { let Some(rv) = v.get("ruby") else { - s.ui_requests.push(json!({"open": "phonetic"})); + s.push_ui_request(json!({"open": "phonetic"})); return Ok(state(s)); }; let text: String = match rv { diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 8305e5ec7..a1824a863 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool { doc.settings.protection.is_some() } +/// Is the document's editing restricted? The formatting-only protection stores the mode "none" +/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it +/// limits the styles, not the editing. +pub fn editing_restricted(doc: &Document) -> bool { + doc.settings.protection.as_deref().is_some_and(|m| m != "none") +} + /// May `author` edit inside the range? fn editable_by(r: &PermRange, author: &str) -> bool { r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim())) @@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms"))); } let formatting = p::bool(v, "formatting"); - let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true)); + let explicit_stop = p::bool(v, "stop") == Some(true); + // Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a + // protected document only `{"stop": true}` stops it — a command that merely names a mode + // can't strip a user's protection (Word asks for Stop Protection first). + if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) { + return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into())); + } + let stop = explicit_stop || (mode == Some("none") && formatting != Some(true)); if stop { return stop_protection(s, p::str(v, "password").unwrap_or("")); } @@ -90,8 +104,16 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { // Only the locked styles changed (or nothing): report the state. return Ok(state(s)); } - if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() { - return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into())); + // The protection of a protected document doesn't change: Word asks for Stop Protection first. + // (The formatting-only protection isn't one: it limits the styles, not the editing — but a + // password on it still guards it, so it can't be replaced either.) + if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() { + let why = if s.doc.settings.protect_hash.is_empty() { + "the document is protected: stop protection first" + } else { + "the document is protected with a password: stop protection first" + }; + return Err(CmdError::Disabled(why.into())); } let formatting = formatting.unwrap_or(false); let mode = mode.unwrap_or("none"); @@ -202,24 +224,92 @@ const CONTROL_INSERTS: [&str; 9] = [ "developer.repeatingSection", ]; -/// Commands always allowed in a protected document: protection itself, file commands, undo. -fn always_allowed(id: &str) -> bool { - id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo" +/// Commands always allowed in a protected document: protection itself, undo, and the file +/// commands that don't change the open document. A `file.*` command that does change it is +/// checked like any other, so the protection holds for it too: the privacy options and the +/// properties are document settings, and the inspector removes content (#1327 follow-up). +fn always_allowed(id: &str, params: &Value) -> bool { + if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" { + return true; + } + if !id.starts_with("file.") { + return false; + } + match id { + "file.privacyOptions" | "file.properties" => false, + // The user name says who may edit the document and who the revisions belong to: it is + // checked like any other change, not waved through. + "file.setAuthor" => false, + // The inspector reads the document; its removals change it. + "file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()), + _ => true, + } +} + +/// The user name (`file.setAuthor`) is the identity a named exception range is checked against, +/// and the author recorded on new revisions and comments: it can't change while the document is +/// protected. Word lets the user name change under protection (it is a preference there); this +/// project doesn't, because the name says who may edit what (#1327 follow-up). +pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into())); + } + Ok(()) +} + +/// A version restore replaces the whole document, including the content the protection kept +/// read-only, so it needs the protection stopped first — and the password with it, when the +/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the +/// command checks this itself — see [`check_version_restore`].) +pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> { + if enforced(&s.doc) { + return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into())); + } + Ok(()) +} + +/// What decides a protected document's outright refusals: whether the current author may edit +/// any exception (`w:permStart`), and how many content controls take entries. Both walk the +/// whole document. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Facts { + pub exceptions: bool, + pub fillable: usize, +} + +impl Facts { + pub(crate) fn of(s: &Session) -> Facts { + Facts { exceptions: s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author)), fillable: s.doc.fillable_controls() } + } } /// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys -/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame. -/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons -/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control. -/// The form's fields stay as they are; only their content is filled in. -pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> { - if s.doc.settings.protection.as_deref() != Some("forms") { +/// it out ([`Session::why_disabled`], with [`Facts`] cached per document revision) and +/// [`check_before`] refuses it. Observed in Word for Mac: with Filling in forms enforced, the +/// Developer tab's control buttons and Properties are greyed out and a field's shortcut menu +/// offers no Remove Content Control. The form's fields stay as they are; only their content is +/// filled in. A document that is read only (or open to comments only) with no exception the +/// author may edit takes no other edits at all. +pub(crate) fn greyed_out(s: &Session, id: &str, facts: impl FnOnce() -> Facts) -> Option<&'static str> { + let mode = s.doc.settings.protection.as_deref()?; + if always_allowed(id, &Value::Null) { return None; } - match id { - "developer.removeControl" => Some("stop protection before removing a content control"), - "developer.properties" => Some("stop protection before changing a content control's properties"), - _ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"), + if mode == "forms" { + match id { + "developer.removeControl" => return Some("stop protection before removing a content control"), + "developer.properties" => return Some("stop protection before changing a content control's properties"), + _ if CONTROL_INSERTS.contains(&id) => return Some("stop protection before inserting a content control"), + _ => {} + } + } + let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply"); + let tracked = matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges"); + match mode { + "readOnly" => (!facts().exceptions).then_some("the document is protected (read only)"), + "forms" => (facts().fillable == 0).then_some("the document is protected (read only)"), + "comments" if !comment_ok => (!facts().exceptions).then_some("only comments are allowed in this document"), + "trackedChanges" if tracked => Some("changes to this document are tracked"), _ => None, } } @@ -229,9 +319,16 @@ pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> { /// [`outside_unchanged`]). pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result { let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) }; - if always_allowed(id) { + if always_allowed(id, params) { return Ok(false); } + // The user name says who may edit the document, and who the new revisions and comments + // belong to: it can't change while the document is protected. (`file.setAuthor` is a pure + // command, so `check_before` doesn't see it; the command checks this itself — see + // [`check_user_name`].) + if id == "file.setAuthor" { + check_user_name(s)?; + } // Word blocks content-control property edits until Stop Protection: changing a control's // lock flags would reshape what forms mode lets through. The param's presence is the // trigger — absent, the command keeps the current lock (as `check_formatting` inspects @@ -240,25 +337,35 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result