diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index efac5e149d..a7b3e0d2f6 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -31,3 +31,5 @@ jobs: with: app-name: wordcraft app-dir: WordCraft + install-relative-path: Programs/ArtCraft/WordCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 44ec9f17be..1983632ac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 9f11dd0226..69d6249018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion. - **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green. -- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`). +- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`). ## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images **This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo. @@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o ## Roadmap Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index b0b896f529..14cbaef7b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,13 +1084,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -6647,7 +6648,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -6682,7 +6683,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", "sys-locale", @@ -6695,11 +6696,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.11.0" +version = "0.12.0" [[package]] name = "wordcraft-doc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.23.1", "log", @@ -6713,7 +6714,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6730,7 +6731,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "base64 0.23.1", @@ -6754,7 +6755,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6785,7 +6786,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "brotli-decompressor", "harfrust 0.13.3", @@ -6799,7 +6800,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chardetng", @@ -6812,12 +6813,13 @@ dependencies = [ "quick-xml 0.41.0", "wordcraft-doc", "wordcraft-geom", + "wordcraft-metafile", "zip 3.0.0", ] [[package]] name = "wordcraft-geom" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "serde", @@ -6825,7 +6827,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "js-sys", @@ -6845,7 +6847,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "serde_json", @@ -6857,7 +6859,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -6865,7 +6867,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-pdf", @@ -6886,7 +6888,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "miniz_oxide 0.9.1", @@ -6896,7 +6898,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "log", @@ -6910,7 +6912,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6941,7 +6943,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -6960,7 +6962,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "proptest", @@ -7123,7 +7125,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 2e2b787d11..ee4c18523c 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.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,7 +59,8 @@ brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -96,9 +97,11 @@ arboard = { version = "3.7", default-features = false, features = ["wayland-data unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" # Line tables keep file and line in panics and backtraces; full debug info diff --git a/README.md b/README.md index b0445a01eb..fbe286f7be 100644 --- a/README.md +++ b/README.md @@ -221,16 +221,17 @@ closer pagination fidelity, a stability sweep and native printing. Details and e ### Windows -| Build | Installer (all users) | Installer (just me) | Portable | -|---|---|---|---| -| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-user-x64.msi` | `wordcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-x86-portable.zip` | -The all-users installer puts WordCraft in Program Files and needs administrator rights. The -just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your -account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two -in the Start menu. Installers and executables are code-signed. +The installer is one dual-scope package. By default it installs for your account only, +into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing +"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build) +and asks for an administrator. Install only one copy: a copy for another scope is refused, so +uninstall first to change who WordCraft is installed for. Installers and executables are code-signed. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's diff --git a/ROADMAP.md b/ROADMAP.md index fee18576de..4eaa61d789 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,8 +4,8 @@ **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-27 · **Change:** trivial (technical synchronization of the proofing pane with extracted engine/UI modules; validation pending); previously minor (Word-observed spelling and grammar task pane; rendered layout and overlapping-issue selection review); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (technical synchronization of the existing Proofing pane with main `3b7f7ed`; new-SHA validation pending); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -140,6 +140,23 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764). + +- 2026-10-28: Ctrl/Command+Shift copies a selected floating picture, shape or text box along its dominant axis (#4764); inline copies keep the text cursor. Guides on the free axis, original/clipboard/selection, cancel, protection and history are covered by synthetic Linux tests. Native desktop and Word untested; readiness unchanged. + +- 2026-10-28: Charts and SmartArt draw text their font lacks in a fallback face, as body text does: Chinese titles, axis labels and legends no longer show boxes (#4720). A run of such characters takes one face that covers it all, so a label doesn't mix serif and sans glyphs; bold labels stay bold. +- 2026-10-28: Custom document properties retain their bookmark link targets through DOCX saves, native JSON reopens, value edits and dialog renames (#4767). Linked values are not evaluated; the existing dialog and field-update behavior are unchanged. Original fixtures cover ordinary and raw property values; broader corpus qualification and readiness estimates remain unchanged. + +- 2026-10-29: Increase/Decrease Font Size and the one-point shortcuts change each selected run from its own resolved size, retaining mixed-size formatting, inherited styles and one-step Undo (#4903). Existing size steps and limits stay unchanged. + +- 2026-10-28: Convert Notes walks reference offsets and formatting runs together instead of rescanning all references for every run (#4750). Note text, IDs, custom marks, styles, history and saved content retain their behavior. Synthetic scaling, CLI/MCP and native/DOCX reopen coverage; readiness estimates unchanged. + +- 2026-10-29: Saving a DOCX with many comments no longer searches all comment paragraph IDs and all reserved durable IDs for each comment. Paragraph IDs use their existing sorted order; durable-ID reservations are indexed once. Parent links, resolved flags, authored text and existing ID rules are unchanged. + +- 2026-10-28: RTF import keeps pictures stored as Windows Metafiles (`\wmetafile8`, as Word writes a pasted or floating picture) and Enhanced Metafiles (`\emfblip`); they were dropped, so such a document opened without its pictures. They are drawn by the metafile renderer, saved to .docx as `.wmf`/`.emf` media, and saved to .rtf as metafiles again (RTF export dropped them too). (#4259) +- 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. +- 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. +- 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. - 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. @@ -172,6 +189,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed. - 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891). - 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738). +- 2026-10-23: Layout: paragraphs that each start a continuous section now go onto new pages. A run of them ran off the bottom of the first page, because each new section counted as the top of a page. Empty section-break paragraphs after the first in a row take a line each until the page is full and then stay at its bottom, so the text after them starts the next page, as measured in Word (#3907). - 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-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs. - 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). @@ -200,7 +218,15 @@ 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-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. | +| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | +| 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | +| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | +| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | +| 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | +| 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | +| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | | 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only | | 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. | @@ -253,6 +279,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-16 | DOCX: a `HYPERLINK` field (complex or simple) reads as a link, as a `w:hyperlink` does: its result stays ordinary text carrying the link (`url`, `url#bookmark` or `#bookmark`), so a tab in it reaches its tab stop and a `PAGEREF` in it stays a field (#2174). A table of contents from Word for the web, whose entries are such fields, showed a box for each tab and no dot leaders; the entries now open as in Word and their links work | | 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard | | 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone | +| 2026-10-15 | Selected table rows and cells copy as a table (#1744): Copy of whole rows, of cells in a row, or of a block of cells over several rows, then Paste outside the table, inserts a table of just those cells; Cut of rows or cells clears their text and leaves the rows and cells in place (the clipboard still gets the table), as Word does. Copy used to take only the cells' paragraphs | +| 2026-10-14 | Tables can be copied and cut (#1744): with the whole table selected (Select Table, or a drag over all of it), Copy puts the table itself on the clipboard, so Paste outside it inserts a copy of the table with its formatting (the plain text is tab and newline separated); Cut removes the table in one undo step, as in Word. Copy used to keep only the cells' paragraphs and Cut left the table in place | | 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-19 | Typing no longer starts render threads on every repaint (#3027): page, area and icon renders share one kept vello_cpu context, reset to each size, instead of building a multi-threaded one (and its thread pool) per render. Typing about 150 characters in 15 s created 295–388 threads before and none now; CPU time while typing fell by about a third (Windows, debug build). | | 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 | @@ -635,17 +663,39 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change | | 2026-10-17 | Saving into a folder that refuses a temporary file (macOS privacy protection, read-only folders, #2138) never overwrites an existing document in place: it is left untouched and the error says to use Save As to another folder or allow WordCraft access to the folder (System Settings › Privacy & Security › Files and Folders on macOS). A new file is created directly. Unit tests on Linux; not yet confirmed on a Mac | | 2026-10-20 | Saving to a NAS share mounted by GVFS (`smb://` in the Linux file manager, #3003) works: the share refuses every Unix mode change with "Operation not supported", which used to fail the whole save, so the close prompt's Save never closed. The save now goes on without copying a mode the folder doesn't keep; any other failure still keeps the original. Unit tests; not yet confirmed on a real share | +| 2026-10-29 | Corporate authors in bibliographies (#4848): a source whose author is an organisation (Word's Corporate Author, `b:Corporate`) keeps it when a Word document opens, instead of losing the author. Citations and bibliography entries write the name as it is in every style ("(World Health Organization, 2021)", never "Organization, W. H."); it gives the source's tag and its place in an author-sorted list, and it saves back as `b:Corporate`. Create Source has a Corporate Author box under Author. | +| 2026-10-29 | Table Properties follows the selection (#4857): with the whole table (or several rows, columns or cells) selected, the Row, Column and Cell settings apply to every selected row, column and cell, as in Word. Before, they changed the caret's row alone, so unticking Allow row to break across pages with the table selected left the other rows breaking. Allow row to break and Repeat as header row show as mixed when the selected rows differ and change nothing unless clicked. | Footer page-table height and body wrapping are corrected. Other pagination gaps remain open. ## Revision history +> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. +> +> 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658). +> > 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. > > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged | +| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) | +| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged | +| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged | +| 2026-10-27 | trivial | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | +| 2026-10-28 | trivial | Preserve custom-property bookmark link targets through saves, value edits and dialog renames (#4767); no linked-value evaluation or readiness change | +| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged | +| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | +| 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. | +| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | +| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | +| 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | +| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | +| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | +| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | +| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | @@ -682,6 +732,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 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 | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged | +| 2026-10-23 | trivial | Progress log: continuous sections paginate, #3907 | | 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) | @@ -750,6 +801,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | +| 2026-10-17 | trivial | Progress log: macOS launch arguments and open failures (#1103) | | 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) | @@ -803,6 +855,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) | | 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) | | 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) | +| 2026-10-15 | trivial | Progress log: selected table rows and cells copy as a table; Cut clears their text (#1744) | +| 2026-10-14 | trivial | Progress log: Select Table copies and cuts the whole table (#1744) | | 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | @@ -1008,3 +1062,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) | | 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) | +| 2026-10-28 | trivial | Progress log: chart and SmartArt text the font lacks is drawn in a fallback face (#4720) | +| 2026-10-29 | trivial | Progress log: corporate authors in bibliographies (#4848) | +| 2026-10-29 | trivial | Progress log: Table Properties follows the selection (#4857) | +| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) | +| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index f13267e6c6..a0ce6419e0 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -391,6 +391,27 @@ fn valid_connect_addr(addr: &str) -> bool { !host.is_empty() && port.parse::().is_ok() } +fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result, Failure> { + match arg_value(rest, "--script") { + Some(path) if !path.starts_with("--") => { + let source = script_path.unwrap_or(Path::new(&path)); + Ok(Some(script::load(source)?)) + } + _ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()), + _ => Ok(None), + } +} + +fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option, Option) { + // A source named on the command line wins over both of the script's. + let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template")); + if from_file.is_none() && from_template.is_none() { + from_file = script.and_then(|loaded| loaded.file.clone()); + from_template = script.and_then(|loaded| loaded.template.clone()); + } + (from_file, from_template) +} + /// Run the subcommand named by `args` (the command line after the program name). Files named /// there are opened by their real names, which may not be valid Unicode. fn run>(args: &[S]) -> Result<(), Failure> { @@ -474,17 +495,8 @@ fn run>(args: &[S]) -> Result<(), Failure> { let page = parse_page_arg("run", &rest)?; let scale = parse_scale_arg("run", &rest)?; let password = password_from(&rest)?; - let script = match arg_value(&rest, "--script") { - Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?), - _ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()), - _ => None, - }; - // A source named on the command line wins over both of the script's. - let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template")); - if from_file.is_none() && from_template.is_none() { - from_file = script.as_ref().and_then(|c| c.file.clone()); - from_template = script.as_ref().and_then(|c| c.template.clone()); - } + let script = load_run_script(&rest, script_path.as_deref())?; + let (from_file, from_template) = run_source_paths(&rest, script.as_ref()); let mut s = match (from_file, from_template) { (Some(f), _) => open(&f, password.as_deref())?, // Both go through `file.new`, which puts a blank document on the region's paper (#3152). diff --git a/apps/wordcraft-web/src/prefs.rs b/apps/wordcraft-web/src/prefs.rs index 1131a7551b..5b3f9a99b6 100644 --- a/apps/wordcraft-web/src/prefs.rs +++ b/apps/wordcraft-web/src/prefs.rs @@ -49,9 +49,9 @@ impl PrefsSync { pub fn changed(&mut self, app: &WordApp) -> Option { let mut prefs = app.prefs(); if let Some((host, own)) = &self.host_author - && prefs.author == *host + && prefs.author.as_deref() == Some(host.as_str()) { - prefs.author = own.clone(); + prefs.author = Some(own.clone()); } let json = match serde_json::to_string(&prefs) { Ok(j) => j, diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 0eae596a9d..e79d923a9a 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { - log::warn!("{p}: {e}"); - let e = e.to_string(); - let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}"); - app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)])); + app.report_open_failure(p, &e); + // Backstage hides the status bar, so leave it to show the error. + app.ui.backstage = false; } } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index bc66c94115..1a9f25d9d8 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -329,6 +329,26 @@ fn text_args() -> (Vec, Vec) { (args, files) } +/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and +/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with +/// the value after each. They are not files, and opening them as files fails. +fn without_launch_args(args: Vec) -> Vec { + let mut kept = Vec::with_capacity(args.len()); + let mut args = args.into_iter().peekable(); + while let Some(a) = args.next() { + if a.starts_with("-psn_") { + continue; + } + if a.starts_with("-NS") || a.starts_with("-Apple") { + // Its value, unless the next argument is another flag. + args.next_if(|v| !v.starts_with('-')); + continue; + } + kept.push(a); + } + kept +} + /// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it /// gets the file's bytes under the lossy name, and the document then keeps the real path to save to. /// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. @@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { // Not when it waits for a password: that open names the document itself. Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()), Ok(_) => {} - Err(e) => log::warn!("{name}: {e}"), + Err(e) => { + app.report_open_failure(&name, &e); + } } } @@ -350,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so the panic hook and every start-up warning are recorded (`logging`). @@ -360,6 +382,8 @@ fn main() -> eframe::Result { let mut files = Vec::new(); let mut sample = false; let (args, other_files) = text_args(); + // Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name. + let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args }; let mut bad_control: Option> = None; let mut args = args.into_iter().peekable(); while let Some(a) = args.next() { @@ -504,7 +528,7 @@ fn main() -> eframe::Result { } for f in files { if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { - log::warn!("{f}: {e}"); + app.report_open_failure(&f, &e); } } for f in other_files { @@ -590,6 +614,33 @@ mod tests { } } +#[cfg(test)] +mod launch_arg_tests { + use super::without_launch_args; + + fn strip(args: &[&str]) -> Vec { + without_launch_args(args.iter().map(|a| a.to_string()).collect()) + } + + #[test] + fn macos_launch_arguments_are_not_files() { + assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]); + assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]); + assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]); + // A flag with no value after it, or followed by another flag, takes nothing with it. + assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]); + assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]); + } + + #[test] + fn real_arguments_are_kept() { + assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]); + // Files whose names merely start like a launch argument keep their place when they are paths. + assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]); + assert!(strip(&[]).is_empty()); + } +} + #[cfg(test)] mod prefs_tests { use super::write_prefs; diff --git a/clippy.toml b/clippy.toml index 287e14edc1..3522a15b69 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index ea73277286..fe41617b58 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -181,3 +181,12 @@ sync_github_name = true [people.pakiat] display_name = "OtorO" + +[people.TobyWu666] +display_name = "tobywu" + +[people.Bra1nZ] +sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/doc/src/block.rs b/crates/doc/src/block.rs index 251aa6e849..540876ec1d 100644 --- a/crates/doc/src/block.rs +++ b/crates/doc/src/block.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Paragraph, Serialize, Table}; /// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.) #[allow(clippy::large_enum_variant)] diff --git a/crates/doc/src/cluster.rs b/crates/doc/src/cluster.rs index b41a8fcc41..4b4db881f9 100644 --- a/crates/doc/src/cluster.rs +++ b/crates/doc/src/cluster.rs @@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool { /// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the /// first is 0 for non-empty text. pub fn cluster_starts(text: &str) -> Vec { + // ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29). + if text.is_ascii() { + let b = text.as_bytes(); + return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect(); + } let mut out: Vec = Vec::new(); let mut prev: Option = None; for (i, g) in text.grapheme_indices(true) { @@ -110,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize { } } -/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked -/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of -/// South and South-East Asia. +/// Characters Backspace removes individually rather than with their whole cluster: Arabic +/// combining marks and characters of the Brahmic scripts of South and South-East Asia. pub fn backspaces_by_char(c: char) -> bool { matches!(u32::from(c), - 0x0900..=0x0DFF // Devanagari … Sinhala + 0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0) + | 0x064B..=0x065F + | 0x0670 + | 0x06D6..=0x06DC + | 0x06DF..=0x06E4 + | 0x06E7..=0x06E8 + | 0x06EA..=0x06ED + | 0x0897..=0x089F + | 0x08CA..=0x08E1 + | 0x08E3..=0x08FF + | 0x10EFA..=0x10EFF + | 0x0900..=0x0DFF // Devanagari … Sinhala | 0x0E00..=0x0EFF // Thai, Lao | 0x0F00..=0x0FFF // Tibetan | 0x1000..=0x109F // Myanmar @@ -188,6 +203,22 @@ mod tests { assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]); } + /// The ASCII shortcut gives the grapheme rules' answer. + #[test] + fn ascii_cluster_starts_match_the_graphemes() { + let graphemes = |t: &str| -> Vec { t.grapheme_indices(true).map(|(i, _)| i).collect() }; + let all: String = (0u8..128).map(char::from).collect(); + for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] { + assert_eq!(cluster_starts(t), graphemes(t), "{t:?}"); + } + for a in 0u8..128 { + for b in 0u8..128 { + let t = format!("{}{}", char::from(a), char::from(b)); + assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}"); + } + } + } + #[test] fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() { // ខ្មែរ: ខ + coeng + ម + ែ, then រ. @@ -229,8 +260,10 @@ mod tests { } #[test] - fn backspace_by_character_in_brahmic_scripts_only() { + fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() { assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្')); + assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}')); assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語')); + assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}')); } } diff --git a/crates/doc/src/compare.rs b/crates/doc/src/compare.rs index 41ca3ca3d5..dc41383adb 100644 --- a/crates/doc/src/compare.rs +++ b/crates/doc/src/compare.rs @@ -1,6 +1,6 @@ use super::block::{Block, Blocks}; use super::parts::Part; -use super::*; +use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table}; pub fn para_block(p: Paragraph) -> Arc { Arc::new(Block::Para(p)) diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index 31ff6814af..4647706892 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -14,7 +14,7 @@ //! outside what's deleted (so deleting across a control's edge empties it rather than breaking //! it); writers call [`Document::balance_controls`] so a saved file always has whole controls. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::{Deserialize, Serialize}; @@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec, depth: usize, out: } /// Positions of the markers without a partner in a block list and the cells inside it. -fn orphans(bl: &Blocks, prefix: &mut Vec, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) { +pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) { if depth > MAX_DEPTH { return; } let events = control_events(bl); let (_, lone) = pair_events(&events); - for k in lone { - let Some(e) = events.get(k) else { continue }; - let mut path = prefix.clone(); - path.push(u32::try_from(e.block()).unwrap_or(u32::MAX)); - match (e, e.off()) { - (_, Some(off)) => out.push((Path(path), off)), - (ControlEvent::Open { .. }, None) => { - // Which of the table's opens: count those before it on the same table. - let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count(); - tables.push((Path(path), nth, true)); + if !lone.is_empty() { + let mut table_counts: HashMap = HashMap::new(); + let mut open_nth = Vec::with_capacity(events.len()); + for e in &events { + if let ControlEvent::Open { block, off: None, .. } = e { + let count = table_counts.entry(*block).or_insert(0); + open_nth.push(*count); + *count += 1; + } else { + open_nth.push(0); + } + } + for k in lone { + let Some(e) = events.get(k) else { continue }; + let mut path = prefix.clone(); + path.push(u32::try_from(e.block()).unwrap_or(u32::MAX)); + match (e, e.off()) { + (_, Some(off)) => out.push((Path(path), off)), + (ControlEvent::Open { .. }, None) => { + // Which of the table's opens: index among table opens on the same table. + let nth = open_nth.get(k).copied().unwrap_or(0); + tables.push((Path(path), nth, true)); + } + (ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)), } - (ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)), } } for (i, b) in bl.iter().enumerate() { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 318bfcc330..fbc911c305 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -5,7 +5,9 @@ use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, use super::position::{Path, Pos, StoryRef}; use super::settings::{CoreProps, CustomProp, Settings}; use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk}; -use super::*; +use std::sync::Arc; + +use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section}; /// [`Document::media`] by content, so adding media is linear in the bytes added rather than a /// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; @@ -793,7 +795,9 @@ impl Document { p.kind = "lpwstr".into(); p.value = value.to_string(); } - None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }), + None => { + self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() }) + } } } diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index b823d892dc..66d483e81e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -434,10 +434,16 @@ impl Document { at.extend(offs.get(k).copied()); } } + // Reference offsets and run boundaries are ordered; do not restart at the first + // reference for every run, including runs after the last reference. + let mut at = at.into_iter().peekable(); let mut start = 0usize; for r in &mut p.runs { let end = start.saturating_add(r.len); - if at.iter().any(|&o| o >= start && o < end) { + while at.peek().is_some_and(|&o| o < start) { + at.next(); + } + if at.peek().is_some_and(|&o| o < end) { swap(&mut r.props.style); } start = end; @@ -792,6 +798,63 @@ impl Document { Fragment { blocks, parts } } + /// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes. + pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option { + Some(self.fragment_of(vec![self.block(story, path)?.clone()])) + } + + /// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid + /// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table. + pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option { + let t = self.table(story, tp)?.slice(rows, cols); + if t.rows.is_empty() { + return None; + } + Some(self.fragment_of(vec![Block::Table(t)])) + } + + /// `blocks` as a fragment: half content controls dropped, text boxes copied along. + fn fragment_of(&self, mut blocks: Vec) -> Fragment { + crate::control::balance_blocks(&mut blocks); + let parts = self.text_box_parts(&blocks); + Fragment { blocks, parts } + } + + /// The table whose whole text `a..b` covers when the range runs across its cells: from the + /// start of its first paragraph to the end of its last. Of the tables holding both ends, the + /// outermost one covered whole (a table nested in a cell when the range covers just it). + /// `None` for a range inside one cell, or one that doesn't cover a whole table. + pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option { + let (a, b) = order(a, b); + if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() { + return None; + } + let mut paths: Option> = None; + // Table block paths are the prefixes of a cell paragraph's path one block deep, then + // every three steps (block, row, cell) deeper. + let mut n = 1usize; + while n.saturating_add(3) <= a.path.0.len() { + let pre = a.path.0.get(..n)?; + if !b.path.0.starts_with(pre) { + return None; + } + let tp = Path(pre.to_vec()); + if self.table(a.story, &tp).is_some() { + let paths = paths.get_or_insert_with(|| self.para_paths(a.story)); + let mut inside = paths.iter().filter(|p| p.0.starts_with(pre)); + if let Some(first) = inside.next() { + let last = inside.next_back().unwrap_or(first); + let end = self.para(a.story, last).map_or(0, Paragraph::len); + if *first == a.path && *last == b.path && b.off >= end { + return Some(tp); + } + } + } + n = n.saturating_add(3); + } + None + } + /// The whole body as a fragment (with its text boxes), to insert into another document. pub fn body_fragment(&self) -> Fragment { let blocks: Vec = self.body.iter().map(|b| (**b).clone()).collect(); @@ -846,6 +909,13 @@ impl Document { } return out; } + // Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744). + if let Some(tp) = self.whole_table(&a, &b) + && let Some(blk) = self.block(a.story, &tp) + { + out.push(blk.clone()); + return out; + } for p in self.paths_between(&a, &b) { if let Some(para) = self.para(a.story, &p) { let mut q = para.clone(); diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index bc64d43cc4..9140a73cd1 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str *kind = ScriptKind::SubSup; here(if action == "script.addSup" { 2 } else { 1 }) } - (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => { - let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1); - // Row and column of the caret's cell. - let mut k = 0; - let mut rc = (0, 0); - for (r, row) in rows.iter().enumerate() { - if c < k + row.len() { - rc = (r, c - k); - break; - } - k += row.len(); - } - let (r, col) = rc; - let (mut tr, mut tc) = (r, col); - match a { - "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { - let at = if a == "matrix.rowAbove" { r } else { r + 1 }; - rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]); - tr = at; - } - "matrix.colBefore" | "matrix.colAfter" if ncols < 64 => { - let at = if a == "matrix.colBefore" { col } else { col + 1 }; - for row in rows.iter_mut() { - row.insert(at.min(row.len()), Vec::new()); - } - tc = at; - } - "matrix.deleteRow" if rows.len() > 1 => { - rows.remove(r.min(rows.len() - 1)); - tr = r.min(rows.len() - 1); - } - "matrix.deleteCol" if ncols > 1 => { - for row in rows.iter_mut() { - if col < row.len() { - row.remove(col); - } - } - tc = col.min(ncols - 2); - } - _ => {} - } - let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::() + tc; - here(flat) - } - (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a { - "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { - let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len()); - rows.insert(at, Vec::new()); - here(at) - } - "eqarr.deleteRow" if rows.len() > 1 => { - let k = c.min(rows.len() - 1); - rows.remove(k); - here(k.min(rows.len() - 1)) - } - _ => inner, - }, + (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here), + (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner), (MNode::GroupChr { top, chr, .. }, "group.flip") => { *top = !*top; *chr = match *chr { @@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str Some(clamp(root, &out)) } +fn apply_matrix_action(rows: &mut Vec>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let (mut row_index, mut column) = (0, 0); + let mut offset = 0; + for (index, row) in rows.iter().enumerate() { + if caret < offset + row.len() { + row_index = index; + column = caret - offset; + break; + } + offset += row.len(); + } + let (mut target_row, mut target_column) = (row_index, column); + match action { + "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { + let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 }; + rows.insert(at.min(rows.len()), vec![Vec::new(); columns]); + target_row = at; + } + "matrix.colBefore" | "matrix.colAfter" if columns < 64 => { + let at = if action == "matrix.colBefore" { column } else { column + 1 }; + for row in rows.iter_mut() { + row.insert(at.min(row.len()), Vec::new()); + } + target_column = at; + } + "matrix.deleteRow" if rows.len() > 1 => { + rows.remove(row_index.min(rows.len() - 1)); + target_row = row_index.min(rows.len() - 1); + } + "matrix.deleteCol" if columns > 1 => { + for row in rows.iter_mut() { + if column < row.len() { + row.remove(column); + } + } + target_column = column.min(columns - 2); + } + _ => {} + } + let flat = rows.iter().take(target_row).map(Vec::len).sum::() + target_column; + here(flat) +} + +fn apply_equation_array_action(rows: &mut Vec, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos { + match action { + "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { + let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len()); + rows.insert(at, Vec::new()); + here(at) + } + "eqarr.deleteRow" if rows.len() > 1 => { + let index = caret.min(rows.len() - 1); + rows.remove(index); + here(index.min(rows.len() - 1)) + } + _ => inner, + } +} + /// Set the limit placement of n-ary operators (`None`: Word's default by operator). pub fn set_limits(nodes: &mut Arg, loc: Option) { for n in nodes.iter_mut() { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 526b7966e1..92979534ce 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -863,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str { } } -fn node(n: &MNode, s: &mut String, depth: usize) { - match n { - MNode::Run(r) => { - let (open, close) = if r.nor { - ("\\text{", "}") - } else { - match (r.sty, r.scr) { - (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), - (_, MScr::Script) => ("\\mathcal{", "}"), - (_, MScr::Fraktur) => ("\\mathfrak{", "}"), - (_, MScr::SansSerif) => ("\\mathsf{", "}"), - (_, MScr::Monospace) => ("\\mathtt{", "}"), - (Some(MSty::Bold), _) => ("\\mathbf{", "}"), - (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), - (Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => { - if FUNCTION_NAMES.contains(&r.text.as_str()) { - s.push('\\'); - s.push_str(&r.text); - s.push(' '); - return; - } - ("\\mathrm{", "}") - } - _ => ("", ""), - } - }; - s.push_str(open); - if r.nor { - text_chars(&r.text, s); - } else { - chars(&r.text, s); - } - s.push_str(close); - } - MNode::Frac { kind, num, den } => match kind { - FracKind::NoBar => { - s.push_str("\\genfrac{}{}{0pt}{}"); - braced(num, s, depth); - braced(den, s, depth); - } - FracKind::Linear | FracKind::Skewed => { - braced(num, s, depth); - s.push('/'); - braced(den, s, depth); - } - FracKind::Bar => { - s.push_str("\\frac"); - braced(num, s, depth); - braced(den, s, depth); - } - }, - MNode::Script { kind, base, sub, sup } => { - if *kind == ScriptKind::Pre { - s.push_str("{}_"); - braced(sub, s, depth); - s.push('^'); - braced(sup, s, depth); - braced(base, s, depth); - return; - } - braced(base, s, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - s.push('_'); - braced(sub, s, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - s.push('^'); - braced(sup, s, depth); - } - } - MNode::Rad { deg, deg_hide, e } => { - s.push_str("\\sqrt"); - if !deg_hide && !deg.is_empty() { - s.push('['); - arg(deg, s, depth); - s.push(']'); - } - braced(e, s, depth); - } - MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => { - match name_of(*chr) { - Some(n) => { - s.push('\\'); - s.push_str(n); - } - None => s.push(*chr), - } - match lim_loc { - Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"), - Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"), - _ => {} - } - if !sub_hide && !sub.is_empty() { - s.push('_'); - braced(sub, s, depth); - } - if !sup_hide && !sup.is_empty() { - s.push('^'); - braced(sup, s, depth); - } - s.push(' '); - braced(e, s, depth); - } - MNode::Delim { beg, end, sep, elems, .. } => { - s.push_str("\\left"); - s.push_str(&delim_tex(*beg)); - let mid = match sep { - Some(c) => format!("\\middle{}", delim_tex(Some(*c))), - None => "\\middle|".to_string(), - }; - for (i, e) in elems.iter().enumerate() { - if i > 0 { - s.push_str(&mid); - } - arg(e, s, depth); - } - s.push_str("\\right"); - s.push_str(&delim_tex(*end)); - } +fn node(node: &MNode, output: &mut String, depth: usize) { + match node { + MNode::Run(run) => run_node(run, output), + MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth), + MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth), + MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth), + MNode::Nary { .. } => nary_node(node, output, depth), + MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth), MNode::Func { name, e } => { - arg(name, s, depth); - braced(e, s, depth); - } - MNode::Lim { upper, e, lim } => { - // `\lim_{…}` keeps its name; other bases stack. - let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str())); - if simple && !upper { - arg(e, s, depth); - s.push('_'); - braced(lim, s, depth); - } else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() { - s.push_str(group_chr_tex(*chr, *top)); - braced(inner, s, depth); - s.push(if *upper { '^' } else { '_' }); - braced(lim, s, depth); - } else { - s.push_str(if *upper { "\\overset" } else { "\\underset" }); - braced(lim, s, depth); - braced(e, s, depth); - } - } - MNode::Acc { chr, e } => { - let cmd = match chr { - '\u{302}' => "hat", - '\u{303}' => "tilde", - '\u{307}' => "dot", - '\u{308}' => "ddot", - '\u{20DB}' => "dddot", - '\u{30C}' => "check", - '\u{306}' => "breve", - '\u{301}' => "acute", - '\u{300}' => "grave", - '\u{305}' | '\u{AF}' => "bar", - '\u{20D6}' => "overleftarrow", - '\u{20E1}' => "overleftrightarrow", - _ => "vec", - }; - s.push('\\'); - s.push_str(cmd); - braced(e, s, depth); + arg(name, output, depth); + braced(e, output, depth); } + MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth), + MNode::Acc { chr, e } => accent_node(*chr, e, output, depth), MNode::Bar { top, e } => { - s.push_str(if *top { "\\overline" } else { "\\underline" }); - braced(e, s, depth); + output.push_str(if *top { "\\overline" } else { "\\underline" }); + braced(e, output, depth); } MNode::BorderBox { e, .. } => { - s.push_str("\\boxed"); - braced(e, s, depth); + output.push_str("\\boxed"); + braced(e, output, depth); } - MNode::Boxed { e } => braced(e, s, depth), + MNode::Boxed { e } => braced(e, output, depth), MNode::GroupChr { chr, top, e } => { - s.push_str(group_chr_tex(*chr, *top)); - braced(e, s, depth); - } - MNode::EqArr { rows } => { - if rows.len() == 1 - && let Some(r) = rows.first() - && crate::math::has_number_mark(r) - { - arg(r, s, depth); - return; - } - s.push_str("\\begin{aligned}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for n in r { - match n { - MNode::Run(run) if !run.lit && run.text.contains('&') => { - for (k, part) in run.text.split('&').enumerate() { - if k > 0 { - s.push('&'); - } - let mut p = run.clone(); - p.text = part.to_string(); - node(&MNode::Run(p), s, depth + 1); - } - } - other => node(other, s, depth + 1), - } - } - } - s.push_str("\\end{aligned}"); - } - MNode::Matrix { rows, .. } => { - s.push_str("\\begin{matrix}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for (j, c) in r.iter().enumerate() { - if j > 0 { - s.push('&'); - } - arg(c, s, depth); - } - } - s.push_str("\\end{matrix}"); + output.push_str(group_chr_tex(*chr, *top)); + braced(e, output, depth); } + MNode::EqArr { rows } => equation_array_node(rows, output, depth), + MNode::Matrix { rows, .. } => matrix_node(rows, output, depth), MNode::Phant { e, .. } => { - s.push_str("\\phantom"); - braced(e, s, depth); + output.push_str("\\phantom"); + braced(e, output, depth); } } } +fn run_node(run: &MRun, output: &mut String) { + let (open, close) = if run.nor { + ("\\text{", "}") + } else { + match (run.sty, run.scr) { + (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), + (_, MScr::Script) => ("\\mathcal{", "}"), + (_, MScr::Fraktur) => ("\\mathfrak{", "}"), + (_, MScr::SansSerif) => ("\\mathsf{", "}"), + (_, MScr::Monospace) => ("\\mathtt{", "}"), + (Some(MSty::Bold), _) => ("\\mathbf{", "}"), + (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), + (Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => { + if FUNCTION_NAMES.contains(&run.text.as_str()) { + output.push('\\'); + output.push_str(&run.text); + output.push(' '); + return; + } + ("\\mathrm{", "}") + } + _ => ("", ""), + } + }; + output.push_str(open); + if run.nor { + text_chars(&run.text, output) + } else { + chars(&run.text, output) + } + output.push_str(close); +} + +fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) { + match kind { + FracKind::NoBar => { + output.push_str("\\genfrac{}{}{0pt}{}"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + FracKind::Linear | FracKind::Skewed => { + braced(numerator, output, depth); + output.push('/'); + braced(denominator, output, depth); + } + FracKind::Bar => { + output.push_str("\\frac"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + } +} + +fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) { + if kind == ScriptKind::Pre { + output.push_str("{}_"); + braced(sub, output, depth); + output.push('^'); + braced(sup, output, depth); + braced(base, output, depth); + return; + } + braced(base, output, depth); + if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { + output.push('_'); + braced(sub, output, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + output.push('^'); + braced(sup, output, depth); + } +} + +fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) { + output.push_str("\\sqrt"); + if !degree_hidden && !degree.is_empty() { + output.push('['); + arg(degree, output, depth); + output.push(']'); + } + braced(expression, output, depth); +} + +fn nary_node(node: &MNode, output: &mut String, depth: usize) { + let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return }; + match name_of(*chr) { + Some(name) => { + output.push('\\'); + output.push_str(name); + } + None => output.push(*chr), + } + match lim_loc { + Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"), + Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"), + _ => {} + } + if !sub_hide && !sub.is_empty() { + output.push('_'); + braced(sub, output, depth); + } + if !sup_hide && !sup.is_empty() { + output.push('^'); + braced(sup, output, depth); + } + output.push(' '); + braced(e, output, depth); +} + +fn delimiter_node(begin: Option, end: Option, separator: Option, elements: &[Arg], output: &mut String, depth: usize) { + output.push_str("\\left"); + output.push_str(&delim_tex(begin)); + let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string()); + for (index, element) in elements.iter().enumerate() { + if index > 0 { + output.push_str(&middle); + } + arg(element, output, depth); + } + output.push_str("\\right"); + output.push_str(&delim_tex(end)); +} + +fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) { + let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str())); + if simple && !upper { + arg(expression, output, depth); + output.push('_'); + braced(limit, output, depth); + } else if let [MNode::GroupChr { chr, top, e: inner }] = expression { + output.push_str(group_chr_tex(*chr, *top)); + braced(inner, output, depth); + output.push(if upper { '^' } else { '_' }); + braced(limit, output, depth); + } else { + output.push_str(if upper { "\\overset" } else { "\\underset" }); + braced(limit, output, depth); + braced(expression, output, depth); + } +} + +fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) { + let command = match character { + '\u{302}' => "hat", + '\u{303}' => "tilde", + '\u{307}' => "dot", + '\u{308}' => "ddot", + '\u{20DB}' => "dddot", + '\u{30C}' => "check", + '\u{306}' => "breve", + '\u{301}' => "acute", + '\u{300}' => "grave", + '\u{305}' | '\u{AF}' => "bar", + '\u{20D6}' => "overleftarrow", + '\u{20E1}' => "overleftrightarrow", + _ => "vec", + }; + output.push('\\'); + output.push_str(command); + braced(expression, output, depth); +} + +fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) { + if rows.len() == 1 + && let Some(row) = rows.first() + && crate::math::has_number_mark(row) + { + arg(row, output, depth); + return; + } + output.push_str("\\begin{aligned}"); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + output.push_str("\\\\"); + } + for child_node in row { + match child_node { + MNode::Run(run) if !run.lit && run.text.contains('&') => { + for (index, part) in run.text.split('&').enumerate() { + if index > 0 { + output.push('&'); + } + let mut piece = run.clone(); + piece.text = part.to_string(); + run_node(&piece, output); + } + } + other => node(other, output, depth + 1), + } + } + } + output.push_str("\\end{aligned}"); +} + +fn matrix_node(rows: &[Vec], output: &mut String, depth: usize) { + output.push_str("\\begin{matrix}"); + for (row_index, row) in rows.iter().enumerate() { + if row_index > 0 { + output.push_str("\\\\"); + } + for (column, cell) in row.iter().enumerate() { + if column > 0 { + output.push('&'); + } + arg(cell, output, depth); + } + } + output.push_str("\\end{matrix}"); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 193dc55892..3959bad1d1 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -501,9 +501,8 @@ impl Paragraph { pub fn next_boundary(&self, off: usize) -> usize { crate::cluster::next_cluster(&self.text, self.clamp(off)) } - /// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts - /// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant - /// at a time, otherwise the previous caret stop. + /// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the + /// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop. pub fn backspace_from(&self, off: usize) -> usize { let off = self.clamp(off); match self.text.get(..off).and_then(|t| t.char_indices().next_back()) { diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 120bb24cfc..86f81c94d1 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// How a floating object is placed. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/para/inline_kinds.rs b/crates/doc/src/para/inline_kinds.rs index 45c9412bc4..686089f7f7 100644 --- a/crates/doc/src/para/inline_kinds.rs +++ b/crates/doc/src/para/inline_kinds.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/para/inline_object.rs b/crates/doc/src/para/inline_object.rs index 2b65a2ec9b..d10bbb9385 100644 --- a/crates/doc/src/para/inline_object.rs +++ b/crates/doc/src/para/inline_object.rs @@ -1,6 +1,6 @@ use super::float::{Float, Wrap, cap_alt}; use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind}; -use super::*; +use super::{CharProps, Deserialize, Serialize}; /// An inline object anchored at a U+FFFC in the paragraph text. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/para/run.rs b/crates/doc/src/para/run.rs index 3c619c8751..10ff4f09e9 100644 --- a/crates/doc/src/para/run.rs +++ b/crates/doc/src/para/run.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize}; /// Object replacement character: marks an inline object in paragraph text. pub const OBJ: char = '\u{FFFC}'; diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 535073bd9e..830aa04307 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -1,6 +1,6 @@ use super::block::Blocks; use super::position::Pos; -use super::*; +use super::{Deserialize, Serialize, props}; /// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the /// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id), diff --git a/crates/doc/src/position.rs b/crates/doc/src/position.rs index 479aab4ae7..800197720a 100644 --- a/crates/doc/src/position.rs +++ b/crates/doc/src/position.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Which story a position is in. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/props/char_props.rs b/crates/doc/src/props/char_props.rs index 04133bad14..32757edc89 100644 --- a/crates/doc/src/props/char_props.rs +++ b/crates/doc/src/props/char_props.rs @@ -1,6 +1,6 @@ use super::color::{Rgb, TextColor}; use super::para_props::Border; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -273,6 +273,12 @@ pub struct CharProps { pub small_caps: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hidden: Option, + /// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`). + #[serde(skip_serializing_if = "Option::is_none")] + pub web_hidden: Option, + /// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on. + #[serde(skip_serializing_if = "Option::is_none")] + pub snap_to_grid: Option, /// Character spacing (expanded/condensed), points. #[serde(skip_serializing_if = "Option::is_none")] pub spacing: Option, @@ -447,24 +453,60 @@ impl CharProps { /// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when /// overlaid on `base`. pub fn minus(mut self, base: &CharProps) -> CharProps { - drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures); + self.minus_text_style(base); + self.minus_run_visibility(base); + self.minus_script_style(base); + self.minus_document_marks(base); self } + fn minus_text_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn minus_run_visibility(&mut self, base: &CharProps) { + drop_same_fields!(self, base; web_hidden, snap_to_grid); + } + + fn minus_script_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn minus_document_marks(&mut self, base: &CharProps) { + drop_same_fields!(self, base; link, ins, del, ligatures); + } + /// Apply every `Some` field of `patch` on top of `self`. /// /// Plain and complex-script properties inherit independently. Commands that intend to /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { - overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures); + self.overlay_text_style(patch); + self.overlay_run_visibility(patch); + self.overlay_script_style(patch); + self.overlay_document_marks(patch); if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } } + + fn overlay_text_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn overlay_run_visibility(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; web_hidden, snap_to_grid); + } + + fn overlay_script_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn overlay_document_marks(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures); + } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); self diff --git a/crates/doc/src/props/color.rs b/crates/doc/src/props/color.rs index 41631279d3..baf2d948d4 100644 --- a/crates/doc/src/props/color.rs +++ b/crates/doc/src/props/color.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// An sRGB colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index cab54173ca..29a8b86850 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -1,6 +1,6 @@ use super::char_props::{NumChange, PropChange}; use super::color::Rgb; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -367,10 +367,23 @@ pub struct ParaProps { impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing, - contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, - suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, - snap_to_grid); + self.overlay_layout(patch); + self.overlay_pagination(patch); + self.overlay_script_layout(patch); + } + + fn overlay_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, + space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders); + } + + fn overlay_pagination(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers, + drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid); + } + + fn overlay_script_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/doc/src/props/table_props.rs b/crates/doc/src/props/table_props.rs index 8ae58b531a..8461ffc029 100644 --- a/crates/doc/src/props/table_props.rs +++ b/crates/doc/src/props/table_props.rs @@ -1,7 +1,7 @@ use super::char_props::PropChange; use super::color::Rgb; use super::para_props::{Align, Borders}; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 49b6904822..af3a8f2abb 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -270,7 +270,12 @@ impl ResolvedPara { drop_cap_margin: p.drop_cap_margin.unwrap_or(false), drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE), drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()), - frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0), + // Explicit zero dimensions cancel a frame; omitted dimensions still fit its text. + frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| { + f.w = f.w.filter(|v| *v != 0.0); + f.h = f.h.filter(|v| *v != 0.0); + f + }), suppress_hyphens: p.suppress_hyphens.unwrap_or(false), suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false), kinsoku: p.kinsoku.unwrap_or(true), diff --git a/crates/doc/src/review.rs b/crates/doc/src/review.rs index 47d9f72328..5a0b96820a 100644 --- a/crates/doc/src/review.rs +++ b/crates/doc/src/review.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, Deserialize, Document, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs index d5a7390383..3d8c341cea 100644 --- a/crates/doc/src/settings.rs +++ b/crates/doc/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Rgb, Serialize, math, section, styles}; /// A bibliography source. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] @@ -10,6 +10,10 @@ pub struct Source { pub kind: String, /// "Last, First; Last, First" pub author: String, + /// A corporate author ("World Health Organization"): written as it is, never split into + /// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`, + /// #4848). + pub corporate: String, pub title: String, pub year: String, pub publisher: String, @@ -39,6 +43,9 @@ pub struct CustomProp { /// the property's XML content kept verbatim (vectors, blobs). pub kind: String, pub value: String, + /// Bookmark associated with this property (`linkTarget`), retained without evaluating it. + #[serde(skip_serializing_if = "Option::is_none")] + pub link_target: Option, } /// Document properties (File › Info). diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 76911dd4f8..abdda89241 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63; pub const MAX_TABLE_DEPTH: usize = 24; impl Table { + /// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells), + /// and whether any cell touches them (Cut clears the same cells it copies). + pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) { + let ((r0, r1), (g0, g1)) = (rows, cols); + let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0); + // Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly + // in them widens them, for every row, so the copy isn't ragged. Each round only widens + // (at most once per grid column), so a bound of the grid's width plus one round is enough. + let (mut lo, mut hi) = (g0, g1.saturating_add(1)); + let mut any = false; + for _ in 0..=MAX_COLS.saturating_add(1) { + let (mut nlo, mut nhi, mut found) = (lo, hi, false); + for row in kept() { + let mut g = 0usize; + for cell in &row.cells { + let end = g.saturating_add(cell.span()); + if g < hi && end > lo { + (nlo, nhi, found) = (nlo.min(g), nhi.max(end), true); + } + g = end; + } + } + any = found; + if (nlo, nhi) == (lo, hi) { + break; + } + (lo, hi) = (nlo, nhi); + } + (lo, hi, any) + } + + /// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid + /// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content + /// controls around rows and the table aren't copied (they'd lose their other end); a cell + /// that continues a vertical merge from a row left out starts the merge instead. + pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table { + let (r0, r1) = rows; + let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0); + let (lo, hi, any) = self.slice_bounds(rows, cols); + let mut out_rows = Vec::new(); + for row in kept() { + let mut g = 0usize; + let mut cells = Vec::new(); + for cell in &row.cells { + let end = g.saturating_add(cell.span()); + if any && g < hi && end > lo { + cells.push(cell.clone()); + } + g = end; + } + if !cells.is_empty() { + out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() }); + } + } + if let Some(first) = out_rows.first_mut() { + for c in &mut first.cells { + if c.props.vmerge == VMerge::Continue { + c.props.vmerge = VMerge::Restart; + } + } + } + let whole = lo == 0 && hi >= self.grid.len(); + let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() }; + let mut props = self.props.clone(); + if !whole { + // Part of the columns: as wide as they are, not the whole table's width. + if props.width.is_some() || props.width_pct.is_some() { + props.width = Some(grid.iter().sum()); + } + props.width_pct = None; + } + Table { props, grid, rows: out_rows, controls: ControlWrap::default() } + } + /// A `rows × cols` table spanning `width` points, "Table Grid" style. pub fn new(rows: usize, cols: usize, width: f32) -> Table { let rows = rows.clamp(1, MAX_ROWS); @@ -566,6 +640,69 @@ impl Table { mod tests { use super::*; + /// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down. + fn merged() -> Table { + let mut t = Table::new(3, 3, 300.0); + t.merge(0, 0, 0, 1); + t.merge(1, 2, 2, 2); + assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::>(), [2, 1]); + assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue)); + t + } + + fn spans(t: &Table) -> Vec> { + t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect() + } + + /// #1744 review: a cell merged across that is partly selected takes its whole width, and the + /// other rows widen with it, so the copy isn't ragged. + #[test] + fn slice_widens_to_a_merged_cell_in_every_row() { + let t = merged().slice((0, 1), (1, 2)); + assert_eq!(t.grid, [100.0, 100.0, 100.0]); + assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]); + // Rows without the merged cell: just the selected columns. + let t = merged().slice((1, 2), (1, 2)); + assert_eq!(t.grid, [100.0, 100.0]); + assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]); + } + + /// #1744 review: a cell continuing a vertical merge from a row left out starts it instead. + #[test] + fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() { + let t = merged().slice((2, 2), (0, 2)); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart); + // Both rows kept: the merge is kept as it is. + let t = merged().slice((1, 2), (2, 2)); + assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue)); + } + + /// #1744 review: part of the columns is as wide as they are; content controls around rows + /// and the table aren't copied. + #[test] + fn slice_width_and_controls() { + let mut t = Table::new(3, 3, 300.0); + (t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0)); + let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 }; + t.controls = wrap.clone(); + t.rows[1].controls = wrap; + let part = t.slice((0, 2), (2, 2)); + assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None)); + let whole = t.slice((0, 2), (0, 2)); + assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width"); + let row = t.slice((1, 1), (0, 2)); + assert!(row.controls.is_empty() && row.rows[0].controls.is_empty()); + // A table sized only by a percentage keeps a width for the part. + let mut pct = Table::new(3, 3, 300.0); + (pct.props.width, pct.props.width_pct) = (None, Some(50.0)); + let part = pct.slice((0, 2), (2, 2)); + assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None)); + // Nothing in range: no rows. + assert!(t.slice((5, 6), (0, 2)).rows.is_empty()); + assert!(t.slice((0, 2), (9, 9)).rows.is_empty()); + } + #[test] fn grid_before_shifts_the_row() { let mut t = Table::new(2, 3, 300.0); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 67256fd78c..26acf90c22 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -1,5 +1,7 @@ use super::*; +mod note_conversion; + #[test] fn new_document_is_valid() { let d = Document::new(); @@ -651,3 +653,66 @@ fn control_markers_kept_are_sorted() { assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}"); } } + +#[test] +fn lone_table_open_markers_linear_time() { + let mut d = Document::new(); + let mut t = Table::new(1, 1, 100.0); + const COUNT: usize = 2_000; + t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect(); + d.body.insert(0, Arc::new(Block::Table(t))); + + let mut paras = Vec::new(); + let mut tables = Vec::new(); + let timer = std::time::Instant::now(); + crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables); + + assert_eq!(paras.len(), 0); + assert_eq!(tables.len(), COUNT); + for (i, (path, nth, is_open)) in tables.iter().enumerate() { + assert_eq!(*path, Path::top(0)); + assert_eq!(*nth, i); + assert!(*is_open); + } + + assert!(d.has_unbalanced_controls()); + let removed = d.balance_controls(); + assert_eq!(removed, COUNT); + assert!(!d.has_unbalanced_controls()); + assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed()); + let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") }; + assert!(tbl.controls.open.is_empty()); +} + +#[test] +fn lone_table_open_markers_mixed_tables_and_paired() { + let mut d = Document::new(); + let mut t0 = Table::new(1, 1, 100.0); + t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect(); + t0.controls.close = 1; + + let mut t1 = Table::new(1, 1, 100.0); + t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect(); + + d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))]; + + let mut paras = Vec::new(); + let mut tables = Vec::new(); + crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables); + + assert_eq!(paras.len(), 0); + assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]); + + let removed = d.balance_controls(); + assert_eq!(removed, 4); + assert!(!d.has_unbalanced_controls()); + + let Block::Table(res0) = &*d.body[0] else { panic!("expected table") }; + assert_eq!(res0.controls.open.len(), 1); + assert_eq!(res0.controls.open[0].tag, "t0_2"); + assert_eq!(res0.controls.close, 1); + + let Block::Table(res1) = &*d.body[1] else { panic!("expected table") }; + assert!(res1.controls.open.is_empty()); + assert_eq!(res1.controls.close, 0); +} diff --git a/crates/doc/src/tests/note_conversion.rs b/crates/doc/src/tests/note_conversion.rs new file mode 100644 index 0000000000..3c9a5e2b43 --- /dev/null +++ b/crates/doc/src/tests/note_conversion.rs @@ -0,0 +1,145 @@ +use super::*; +use crate::para::{InlineObject, NoteKind, OBJ}; + +fn reference(id: u32, kind: NoteKind) -> InlineObject { + InlineObject::NoteRef { kind, id, custom: "*".into() } +} + +fn styled(len: usize, style: &str) -> Run { + Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } } +} + +#[test] +fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() { + for (foot, end, expected) in [ + (true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]), + (false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + (true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + ] { + let mut d = Document::new(); + let note = |style: &str| { + let mut p = Paragraph::with_text("Original note 漢", CharProps::default()); + p.props.style = Some(style.into()); + p.mark.style = Some(style.into()); + vec![para_block(p)] + }; + let f = d.add_part(PartKind::Footnote, note("FootnoteText")); + let e = d.add_part(PartKind::Endnote, note("EndnoteText")); + let p = Paragraph { + text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"), + objects: vec![ + reference(f, NoteKind::Footnote), + InlineObject::BookmarkStart { name: "kept".into() }, + reference(e, NoteKind::Endnote), + reference(999, NoteKind::Footnote), + reference(f, NoteKind::Footnote), + ], + runs: vec![ + styled(2, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "FootnoteReference"), + ], + ..Paragraph::new() + }; + let mut table = Table::new(1, 1, 200.0); + table.rows[0].cells[0].blocks = vec![para_block(p.clone())]; + d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))]; + let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]); + let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]); + let before = d.clone(); + assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end)); + for (story, path) in [ + (StoryRef::Body, Path::top(0)), + (StoryRef::Body, Path(vec![1, 0, 0, 0])), + (StoryRef::Part(h), Path::top(0)), + (StoryRef::Part(t), Path::top(0)), + ] { + let got = d.para(story, &path).unwrap(); + assert_eq!(got.text, p.text); + assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), expected); + assert!(got.runs.iter().all(|r| r.props.italic == Some(true))); + assert_eq!(got.objects[1], p.objects[1]); + assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged"); + assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })); + assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote })); + assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion"); + } + for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] { + let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap(); + assert_eq!(got.text, before.plain_text(StoryRef::Part(id))); + assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from })); + assert_eq!(got.mark.style, got.props.style); + } + let mut missing = Paragraph { + text: format!("{OBJ}{OBJ}"), + objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })], + runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")], + ..Paragraph::new() + }; + missing.runs.push(styled(usize::MAX, "FootnoteReference")); + d.body = vec![para_block(missing)]; + d.convert_notes(true, true); + let got = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!( + got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), + ["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"] + ); + } +} + +fn dense(n: usize) -> Document { + let mut d = Document::new(); + let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]); + d.body = vec![para_block(Paragraph { + text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)), + objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(), + runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(), + ..Paragraph::new() + })]; + d +} + +// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing +// evidence; the semantic assertions still run on those platforms. +fn cpu_cost(f: impl FnOnce()) -> Option { + let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::().ok(); + let before = ran(); + f(); + match (before, ran()) { + (Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9), + _ => None, + } +} + +#[test] +fn conversion_work_scales_with_reference_and_formatting_runs() { + let (small, big) = (dense(2_000), dense(16_000)); + let convert = |input: &Document| { + let mut d = input.clone(); + assert_eq!(d.convert_notes(true, false), 1); + let p = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + let n = p.objects.len(); + assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference"))); + assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference"))); + std::hint::black_box(d); + }; + convert(&small); + convert(&big); + let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY); + for _ in 0..3 { + let a = cpu_cost(|| (0..8).for_each(|_| convert(&small))); + let b = cpu_cost(|| convert(&big)); + if let (Some(a), Some(b)) = (a, b) { + t1 = t1.min(a / 8.0); + t8 = t8.min(b); + } + } + if t1.is_finite() && t8.is_finite() { + let ratio = t8 / t1; + eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}"); + assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU"); + } +} diff --git a/crates/doc/src/text_walk.rs b/crates/doc/src/text_walk.rs index 799f79893d..d7a681a5f8 100644 --- a/crates/doc/src/text_walk.rs +++ b/crates/doc/src/text_walk.rs @@ -1,7 +1,7 @@ use super::block::{Block, Blocks}; use super::parts::PartKind; use super::position::Path; -use super::*; +use super::{InlineObject, Numbering, numbering, para, section, styles}; /// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char). pub fn count_words(s: &str) -> usize { diff --git a/crates/docx/src/bibliography.rs b/crates/docx/src/bibliography.rs index 0b6776825b..495c21e50d 100644 --- a/crates/docx/src/bibliography.rs +++ b/crates/docx/src/bibliography.rs @@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources { let tag = child_text(src_el, "Tag"); let raw_kind = child_text(src_el, "SourceType"); let kind = crate::write::from_source_type(&raw_kind); - let (author, editor) = match src_el.els().find(|e| e.local() == "Author") { + let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") { Some(a) => { - let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a)); + let role = a.els().find(|e| e.local() == "Author"); + let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a)); + // Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848). + let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default(); let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default(); - (auth, ed) + (auth, corporate, ed) } - None => (String::new(), String::new()), + None => (String::new(), String::new(), String::new()), }; let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor }; let title = child_text(src_el, "Title"); @@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources { }; // Keep sources with some content, once each (by tag, or title and author untagged). - if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() { + if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() { continue; } let fresh = if tag.is_empty() { - seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() })) + let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default(); + seen.untagged.insert((title.clone(), who)) } else { seen.tags.insert(tag.clone()) }; @@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources { tag, kind, author, + corporate, title, year, city, @@ -265,6 +270,25 @@ mod tests { assert!(read_sources(br#""#).is_err(), "an unrelated namespace"); } + /// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and + /// an untagged source with only a corporate author is still kept, once. + #[test] + fn a_corporate_author_is_read_whole() { + let xml = br#" + Wor20Report + World Health Organization + World report on hearing2020 + Book + Harbor Guild, Inc. + Book + Harbor Guild, Inc. +"#; + let f = read_sources(xml).unwrap(); + assert_eq!(f.sources.len(), 2, "{:?}", f.sources); + assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", "")); + assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name"); + } + /// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older /// builds, the VBA `Source.XML` format) reads as well as OOXML's. #[test] diff --git a/crates/docx/src/crypt/tests.rs b/crates/docx/src/crypt/tests.rs index 44421be745..0b316061bd 100644 --- a/crates/docx/src/crypt/tests.rs +++ b/crates/docx/src/crypt/tests.rs @@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() { assert!(decrypt(&fake, Some("pw")).is_err()); } +/// The key a real-world Standard-encrypted package derives: Apache POI's test file +/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel +/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code — +/// the file decrypts to a valid package under it and nothing else opens that file — so real +/// Word 2007 documents stay pinned, not just spec-derived ones. +#[test] +fn standard_key_matches_a_real_world_package() { + let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e]; + const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c"; + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY); + // A different password or salt gives a different key; the key is exactly the length + // asked for, for each AES width. + assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY); + assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY); + assert_eq!(standard_key(&salt, "tika", 24).len(), 24); + assert_eq!(standard_key(&salt, "tika", 32).len(), 32); +} + /// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the /// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib) /// following the specification's steps, so the encryptor and decryptor can't share a mistake. diff --git a/crates/docx/src/custom.rs b/crates/docx/src/custom.rs index a208441b5a..aa49c45879 100644 --- a/crates/docx/src/custom.rs +++ b/crates/docx/src/custom.rs @@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec { let Some(v) = p.els().next() else { continue }; let kind = v.local(); let prop = if SIMPLE.contains(&kind) { - CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() } + CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) } } else { let mut xml = String::new(); el_xml(v, &mut xml, 0); - CustomProp { name: name.to_string(), kind: "raw".into(), value: xml } + CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) } }; out.push(prop); } @@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec { if p.kind == "raw" && !raw_is_wellformed(&p.value) { continue; } - w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]); + let pid = pid.to_string(); + let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)]; + if let Some(target) = &p.link_target { + attrs.push(("linkTarget", target)); + } + w.open("property", &attrs); if p.kind == "raw" { w.raw(&p.value); } else { diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs index 66e92ae0b8..5ab5512e92 100644 --- a/crates/docx/src/fonts.rs +++ b/crates/docx/src/fonts.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use wordcraft_doc::Document; -use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART}; +use crate::package::{EMBEDDED_PARTS, EmbeddedManifest}; /// Most font files taken from one document. const MAX_FONTS: usize = 64; @@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec { /// [`embedded_fonts`], copying at most `budget` bytes of font files. fn fonts_within(doc: &Document, mut budget: u64) -> Vec { let budget_total = budget; - let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else { + let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); + // The table is kept under the spelling the package stores it as (#3872). + let Some(key) = crate::package::font_table_key(&doc.passthrough) else { return Vec::new(); }; - let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); - let Some(part) = manifest.parts.get(FONT_TABLE_PART) else { + let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else { + return Vec::new(); + }; + let Some(part) = manifest.parts.get(key) else { return Vec::new(); }; let mut out = Vec::new(); @@ -118,6 +122,7 @@ mod tests { use std::sync::Arc; use super::*; + use crate::package::FONT_TABLE_PART; /// A document whose font table makes `refs` references (`(font name, part)`) to font parts /// `parts` (`(name, size)`). diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index a7511fd2bc..bdefc38fa7 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -96,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64; pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts"; /// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds. pub const FONT_TABLE_PART: &str = "word/fontTable.xml"; + +/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a +/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`]. +pub fn font_table_key(parts: &BTreeMap) -> Option<&str> { + if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) { + return Some(k.as_str()); + } + parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str) +} /// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`. /// Their opaque bytes and related parts use the same bounded manifest as embedded objects. pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots"; @@ -412,7 +421,8 @@ fn rel_targets(files: &BTreeMap>>) -> HashSet { let mut named = HashSet::new(); for (name, bytes) in files { // `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`. - let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue }; + let lower = name.to_ascii_lowercase(); + let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { continue }; let dir = name.get(..at).unwrap_or("").trim_end_matches('/'); let Ok(root) = xml::parse(bytes) else { continue }; for r in root.els().filter(|e| e.local() == "Relationship") { diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 878dd487dd..8f77ec63c5 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if let Some(p) = part(rt::SETTINGS) { lenient("settings", r.read_settings(&p)); } - if let Some(p) = part(rt::FOOTNOTES) { - lenient("footnotes", r.read_notes(&p, true)); - } - if let Some(p) = part(rt::ENDNOTES) { - lenient("endnotes", r.read_notes(&p, false)); - } + // Comments before notes: a comment range inside a footnote or endnote resolves its id against + // the comments already read, and was dropped when the notes came first. if let Some(p) = part(rt::COMMENTS) { let ex = part(rt::COMMENTS_EX); let ids = part(rt::COMMENTS_IDS); let cex = part(rt::COMMENTS_EXTENSIBLE); lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref())); } + if let Some(p) = part(rt::FOOTNOTES) { + lenient("footnotes", r.read_notes(&p, true)); + } + if let Some(p) = part(rt::ENDNOTES) { + lenient("endnotes", r.read_notes(&p, false)); + } // Bibliography sources live in a custom XML data store (`b:Sources`), related from the // package root or the main part; Word's own files sometimes leave the relationship out, so // the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once. diff --git a/crates/docx/src/read/chart/canvas.rs b/crates/docx/src/read/chart/canvas.rs index f83ab3db41..a8280e4ba7 100644 --- a/crates/docx/src/read/chart/canvas.rs +++ b/crates/docx/src/read/chart/canvas.rs @@ -1,5 +1,5 @@ use super::model::Pos; -use super::*; +use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign}; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Rect { diff --git a/crates/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs index 807e9e6eff..a3ee99637a 100644 --- a/crates/docx/src/read/chart/model.rs +++ b/crates/docx/src/read/chart/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Rgb; /// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in /// white on the fill) or just beside it (above, or right of a horizontal bar's end). diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs index 5b6551268b..737effde32 100644 --- a/crates/docx/src/read/chart/parse.rs +++ b/crates/docx/src/read/chart/parse.rs @@ -1,7 +1,7 @@ use super::format::{date_text, excel_stamp, format_value, is_date_code}; use super::model::{Axis, Grouping, Kind, Pos, Series}; use super::plot::label; -use super::*; +use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot}; impl Chart { pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option { diff --git a/crates/docx/src/read/chart/plot.rs b/crates/docx/src/read/chart/plot.rs index 5857f51e73..652ee79a8b 100644 --- a/crates/docx/src/read/chart/plot.rs +++ b/crates/docx/src/read/chart/plot.rs @@ -3,7 +3,7 @@ use super::format::format_value; use super::model::{DataLabel, Grouping, Kind, Series}; use super::parse::category_text; use super::polar::{draw_data_labels, label_of}; -use super::*; +use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure}; pub(super) fn label(s: &str) -> String { s.chars().take(MAX_LABEL).collect() diff --git a/crates/docx/src/read/chart/polar.rs b/crates/docx/src/read/chart/polar.rs index f8f4f0b382..57ad10472b 100644 --- a/crates/docx/src/read/chart/polar.rs +++ b/crates/docx/src/read/chart/polar.rs @@ -2,7 +2,7 @@ use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest}; use super::format::format_value; use super::model::{DataLabel, Kind, Series}; use super::plot::{Axes, has_values}; -use super::*; +use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign}; pub(super) fn pie(out: &mut Vec, c: &Chart, area: Rect) { let Some(s) = c.series.first() else { return }; diff --git a/crates/docx/src/read/meta.rs b/crates/docx/src/read/meta.rs index 5ec53de6f3..9739f66dce 100644 --- a/crates/docx/src/read/meta.rs +++ b/crates/docx/src/read/meta.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, + PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, +}; impl Reader<'_> { pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index cc3bfbcd24..f5ce13834e 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels, + Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style, +}; impl Reader<'_> { /// Keep the parts the VBA project at `project` relates to, with their relationship types and diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index acd56b2ef4..ed5ccf0d03 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame { let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string(); let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string); wordcraft_doc::props::TextFrame { - w: len("w:w").filter(|v| *v > 0.0), - h: len("w:h").map(f32::abs).filter(|v| *v > 0.0), + w: len("w:w").filter(|v| *v >= 0.0), + h: len("w:h").map(f32::abs), h_exact: k.attr("w:hRule") == Some("exact"), x: len("w:x").unwrap_or(0.0), y: len("w:y").unwrap_or(0.0), @@ -258,6 +258,8 @@ impl PropCtx { "w:imprint" => c.engrave = Some(on_off(k)), "w:noProof" => c.no_proof = Some(on_off(k)), "w:vanish" => c.hidden = Some(on_off(k)), + "w:webHidden" => c.web_hidden = Some(on_off(k)), + "w:snapToGrid" => c.snap_to_grid = Some(on_off(k)), "w:rtl" => c.rtl = Some(on_off(k)), "w:color" => { c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) { diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 3789690e5d..b1b26ca835 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, Block, Blocks, Cell, CharProps, El, InlineObject, MAX_COLS, MAX_ROWS, MAX_TABLE_DEPTH, Node, NumChange, PB, Paragraph, Phase, PropChange, + Reader, Rels, RevisionKind, Row, Run, RunCtx, StoryCtx, Table, children_of_choice, clean_text, collect, last_para_mut, para_block, + placeholder_of, sectpr, tcpr, trpr, +}; impl Reader<'_> { /// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…). diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index ab8df77486..f8cd077307 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AltText, Anchor, Arc, Blocks, CHARTEX_URI, ConnEnd, ContentTypes, El, Embedded, Float, FloatAlign, Glow, Graphic, GraphicItem, GraphicKind, + INK_REL, INK_URI, InlineObject, MAX_STORY_DEPTH, Node, PartKind, Reader, Rels, Rgb, Shadow, ShapeEffects, ShapeExtra, ShapeKind, StoryCtx, + TextWarp, Wrap, WrapText, cap_alt, children_of_choice, int, measure, on_off_attr, own_fill, preset_kind, rt, shape_paint, u32_of, +}; impl Reader<'_> { // ---- drawings ---- diff --git a/crates/docx/src/read/story/fields.rs b/crates/docx/src/read/story/fields.rs index f661300b67..1847d3e8ad 100644 --- a/crates/docx/src/read/story/fields.rs +++ b/crates/docx/src/read/story/fields.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx}; /// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word /// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text diff --git a/crates/docx/src/read/story/runs.rs b/crates/docx/src/read/story/runs.rs index 222a46f563..9dec4c5b48 100644 --- a/crates/docx/src/read/story/runs.rs +++ b/crates/docx/src/read/story/runs.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx, + TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink, +}; impl Reader<'_> { pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) { @@ -105,7 +108,7 @@ impl Reader<'_> { // Some generators write a break directly under the paragraph instead of inside a run. "w:br" | "w:cr" => { let props = self.run_props_none(ctx); - self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut None, depth + 1); } _ => {} } @@ -131,21 +134,19 @@ impl Reader<'_> { }; let mut note: Option<(NoteKind, u32, Option)> = None; for k in r.els() { - self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut note, depth + 1); } if let Some((kind, id, custom)) = note.take() { self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props); } } - #[allow(clippy::too_many_arguments)] fn read_run_child( &mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, - props: &CharProps, - rels: &Rels, + (props, rels): (&CharProps, &Rels), note: &mut Option<(NoteKind, u32, Option)>, depth: usize, ) { @@ -301,7 +302,7 @@ impl Reader<'_> { self.emit_obj(sc, pb, o, props); } else if let Some(c) = children_of_choice(k) { for x in c.els() { - self.read_run_child(sc, pb, x, props, rels, note, depth + 1); + self.read_run_child(sc, pb, x, (props, rels), note, depth + 1); } } } diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index e3fd56b024..75faa2c91a 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -1,4 +1,14 @@ -use super::*; +use super::{ + Anchor, Arc, CharProps, El, Float, FloatAlign, InlineObject, MAX_STORY_DEPTH, MAX_VML_GROUP_DEPTH, Paragraph, PartKind, Reader, Rels, Rgb, + ShapeExtra, ShapeKind, StoryCtx, TextColor, TextWarp, Wrap, int, measure, para_block, vml_color, vml_fill, vml_fraction, vml_preset_kind, +}; + +#[derive(Clone, Copy)] +struct VmlGroupSpace { + origin: (f32, f32), + scale: (f32, f32), + depth: usize, +} impl Reader<'_> { /// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any @@ -43,7 +53,7 @@ impl Reader<'_> { let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0))); let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0)); let mut children = Vec::new(); - self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children); + self.vml_group_members(sc, pict, grp, rels, VmlGroupSpace { origin: (-cx, -cy), scale: (1.0, 1.0), depth: 0 }, &mut children); if children.is_empty() { return None; } @@ -59,11 +69,11 @@ impl Reader<'_> { pict: &El, g: &El, rels: &Rels, - t: (f32, f32, f32, f32), - depth: usize, + space: VmlGroupSpace, out: &mut Vec, ) { - let (x0, y0, sx, sy) = t; + let (x0, y0) = space.origin; + let (sx, sy) = space.scale; let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); for e in g.els() { @@ -77,7 +87,7 @@ impl Reader<'_> { let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite); let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height"))); if e.local() == "group" { - if depth >= MAX_VML_GROUP_DEPTH { + if space.depth >= MAX_VML_GROUP_DEPTH { continue; } // Its members' space maps onto its own box. @@ -85,7 +95,7 @@ impl Reader<'_> { let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0)); let (kx, ky) = (ew / cw, eh / ch); let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky); - self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out); + self.vml_group_members(sc, pict, e, rels, VmlGroupSpace { origin: (nt.0, nt.1), scale: (nt.2, nt.3), depth: space.depth + 1 }, out); continue; } let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue }; diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 7a97f02f9c..cf9bccf51b 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts, + VAlign, flag, int, props, tw, u32_of, +}; /// Built-in style names Word stores in lower case, and how the UI shows them. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ @@ -39,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String { n.to_string() } +/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`. +fn builtin_heading_level(name: &str) -> Option { + let name = name.trim(); + let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?; + digit.parse::().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1) +} + impl Reader<'_> { pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> { /// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB). @@ -112,7 +122,7 @@ impl Reader<'_> { priority: s.child_val("w:uiPriority").and_then(u32_of), quick: flag(s, "w:qFormat").unwrap_or(false), hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false), - builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"), + builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")), locked: flag(s, "w:locked").unwrap_or(false), // Only where the writer couldn't tell from the id (it flags the English ids). default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true") @@ -128,6 +138,16 @@ impl Reader<'_> { if let Some(p) = s.child("w:pPr") { st.para = self.pc.ppr(p).0; } + // Word gives its built-in heading styles their outline level whether or not the file + // writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the + // Navigation pane and the table of contents, whatever its id (#4696). + if kind == StyleKind::Paragraph + && st.builtin + && st.para.outline_level.is_none() + && let Some(level) = s.child_val("w:name").and_then(builtin_heading_level) + { + st.para.outline_level = Some(level); + } if let Some(rp) = s.child("w:rPr") { st.chr = self.pc.rpr(rp); } diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs index d7307b73c3..1c246732a2 100644 --- a/crates/docx/src/read/toc.rs +++ b/crates/docx/src/read/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Block, Blocks, ControlEvent, ControlKind, Document, HashSet, InlineObject, OBJ, StyleKind, control_events, pair_events}; /// Word draws a table of contents' entry links without the Hyperlink character style (no blue, /// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run diff --git a/crates/docx/src/smart_art.rs b/crates/docx/src/smart_art.rs index 22eda0567c..9378192a87 100644 --- a/crates/docx/src/smart_art.rs +++ b/crates/docx/src/smart_art.rs @@ -805,16 +805,17 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String { /// A layout node: `alg` with its parameters, a shape (`None`: no geometry), what text it shows /// (`presOf` attributes), then `body`. -fn layout_node( - w: &mut W, - name: &str, - style: Option<&str>, - alg: &str, - params: &[(&str, &str)], - shape: Option<&str>, - pres_of: &[(&str, &str)], - body: impl FnOnce(&mut W), -) { +struct LayoutNode<'a> { + name: &'a str, + style: Option<&'a str>, + alg: &'a str, + params: &'a [(&'a str, &'a str)], + shape: Option<&'a str>, + pres_of: &'a [(&'a str, &'a str)], +} + +fn layout_node(w: &mut W, node: LayoutNode<'_>, body: impl FnOnce(&mut W)) { + let LayoutNode { name, style, alg, params, shape, pres_of } = node; let mut attrs = vec![("name", name)]; if let Some(s) = style { attrs.push(("styleLbl", s)); @@ -870,45 +871,74 @@ pub fn layout_xml(l: SmartArtLayout) -> String { SmartArtLayout::Cycle | SmartArtLayout::Radial | SmartArtLayout::Venn => "ellipse", SmartArtLayout::Pyramid => "trapezoid", }; - layout_node(&mut w, "diagram", None, alg, params, None, &[], |w| match l { + layout_node(&mut w, LayoutNode { name: "diagram", style: None, alg, params, shape: None, pres_of: &[] }, |w| match l { SmartArtLayout::Hierarchy => { w.open("dgm:forEach", &[("name", "branches"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "branch", None, "hierRoot", &[], None, &[], |w| { - layout_node(w, "item", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |_| {}); - layout_node(w, "below", None, "hierChild", &[("chAlign", "t")], None, &[], |w| { - w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); - layout_node( - w, - "join", - Some("connector"), - "conn", - &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], - Some("conn"), - &[("axis", "self")], - |_| {}, - ); - w.close("dgm:forEach"); - w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); - }); + layout_node(w, LayoutNode { name: "branch", style: None, alg: "hierRoot", params: &[], shape: None, pres_of: &[] }, |w| { + layout_node( + w, + LayoutNode { name: "item", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |_| {}, + ); + layout_node( + w, + LayoutNode { name: "below", style: None, alg: "hierChild", params: &[("chAlign", "t")], shape: None, pres_of: &[] }, + |w| { + w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); + layout_node( + w, + LayoutNode { + name: "join", + style: Some("connector"), + alg: "conn", + params: &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], + shape: Some("conn"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); + w.close("dgm:forEach"); + w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); + }, + ); }); w.close("dgm:forEach"); } SmartArtLayout::Radial => { w.open("dgm:forEach", &[("name", "middle"), ("axis", "ch"), ("ptType", "node"), ("cnt", "1")]); - layout_node(w, "centre", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |w| { - w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "spoke", Some("node1"), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); - w.close("dgm:forEach"); - }); + layout_node( + w, + LayoutNode { name: "centre", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |w| { + w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); + layout_node( + w, + LayoutNode { name: "spoke", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, + |_| {}, + ); + w.close("dgm:forEach"); + }, + ); w.close("dgm:forEach"); } _ => { w.open("dgm:forEach", &[("name", "items"), ("axis", "ch"), ("ptType", "node")]); let style = if l == SmartArtLayout::Venn { "venn" } else { "node1" }; - layout_node(w, "item", Some(style), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); + layout_node(w, LayoutNode { name: "item", style: Some(style), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, |_| {}); if matches!(l, SmartArtLayout::Process | SmartArtLayout::Cycle) { w.open("dgm:forEach", &[("name", "arrows"), ("axis", "followSib"), ("ptType", "sibTrans"), ("cnt", "1")]); - layout_node(w, "arrow", Some("connector"), "conn", &[("dim", "2D")], Some("rightArrow"), &[("axis", "self")], |_| {}); + layout_node( + w, + LayoutNode { + name: "arrow", + style: Some("connector"), + alg: "conn", + params: &[("dim", "2D")], + shape: Some("rightArrow"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); w.close("dgm:forEach"); } w.close("dgm:forEach"); diff --git a/crates/docx/src/write/bibliography.rs b/crates/docx/src/write/bibliography.rs index 2f0f8b9a36..91dfb23f57 100644 --- a/crates/docx/src/write/bibliography.rs +++ b/crates/docx/src/write/bibliography.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, W}; fn to_source_type(kind: &str) -> &str { match kind.to_ascii_lowercase().as_str() { @@ -121,10 +121,16 @@ pub(super) fn bibliography_xml(doc: &Document) -> Vec { w.open("b:Source", &[]); w.leaf("b:Tag", &[], &src.tag); w.leaf("b:SourceType", &[], to_source_type(&src.kind)); - // Author & Editor - if !src.author.is_empty() || !src.editor.is_empty() { + // Author & Editor. A role holds a name list or a corporate name, not both (ECMA-376 + // `CT_NameOrCorporateType`), so a corporate author takes the author's place (#4848). + let corporate = src.corporate.trim(); + if !src.author.is_empty() || !corporate.is_empty() || !src.editor.is_empty() { w.open("b:Author", &[]); - if !src.author.is_empty() { + if !corporate.is_empty() { + w.open("b:Author", &[]); + w.leaf("b:Corporate", &[], corporate); + w.close("b:Author"); + } else if !src.author.is_empty() { w.open("b:Author", &[]); write_name_list(&mut w, &src.author); w.close("b:Author"); diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 405b901e4f..cc13df58ee 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -14,8 +14,7 @@ use wordcraft_doc::para::Float; use super::PartRels; use crate::package::{ - CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, - rel_is, relative, rt, + CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt, }; use crate::units::{MAX_LEN_PT, emu, int}; use crate::xml::{self, El, MAX_DEPTH}; @@ -105,7 +104,8 @@ impl<'d> EmbedWriter<'d> { /// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`. pub fn font_table(&mut self, rels: &mut PartRels) { let mut written = Vec::new(); - if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) { + let Some(root) = crate::package::font_table_key(self.passthrough).map(str::to_string) else { return }; + if let Some(name) = self.copy_tree(&root, &mut HashMap::new(), &mut written) { rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false); } } @@ -747,7 +747,25 @@ fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option> { b"date" if dates && wml_attr => None, b"dateUtc" if dates => None, _ => { - el.push_attribute(a); + // `push_attribute` writes the value as given, inside `"`. In well-formed + // markup the raw value holds no `<` and every `&` starts a valid + // reference, so only a `"` from a single-quoted value needs escaping; + // references such as ` ` stay as written (#3872). + let raw = a.value.as_ref(); + let value = if raw.contains(&b'"') { + let mut v = Vec::with_capacity(raw.len() + 8); + for &c in raw { + if c == b'"' { + v.extend_from_slice(b"""); + } else { + v.push(c); + } + } + Cow::Owned(v) + } else { + Cow::Borrowed(raw) + }; + el.push_attribute(Attribute { key: a.key, value }); continue; } }; diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs index a1ae2a8c5b..5cacc0b51f 100644 --- a/crates/docx/src/write/math.rs +++ b/crates/docx/src/write/math.rs @@ -179,6 +179,67 @@ fn char_prop(w: &mut W, name: &str, c: Option) { val(w, name, &s); } +fn write_matrix(w: &mut W, n: &MNode, depth: usize) { + let MNode::Matrix { rows, col_jc } = n else { return }; + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); + w.open("m:m", &[]); + w.open("m:mPr", &[]); + w.open("m:mcs", &[]); + for c in 0..cols { + let jc = match col_jc.get(c).copied().unwrap_or_default() { + ColJc::Center => "center", + ColJc::Left => "left", + ColJc::Right => "right", + }; + w.open("m:mc", &[]); + w.open("m:mcPr", &[]); + val(w, "m:count", "1"); + val(w, "m:mcJc", jc); + w.close("m:mcPr"); + w.close("m:mc"); + } + w.close("m:mcs"); + ctrl(w); + w.close("m:mPr"); + for row in rows { + w.open("m:mr", &[]); + for c in 0..cols { + arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); + } + w.close("m:mr"); + } + w.close("m:m"); +} + +fn write_nary(w: &mut W, n: &MNode, depth: usize) { + let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return }; + w.open("m:nary", &[]); + w.open("m:naryPr", &[]); + if *chr != '∫' { + char_prop(w, "m:chr", Some(*chr)); + } + match lim_loc { + Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), + Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), + None => {} + } + if *grow { + val(w, "m:grow", "1"); + } + if *sub_hide { + val(w, "m:subHide", "1"); + } + if *sup_hide { + val(w, "m:supHide", "1"); + } + ctrl(w); + w.close("m:naryPr"); + arg(w, "m:sub", sub, depth); + arg(w, "m:sup", sup, depth); + arg(w, "m:e", e, depth); + w.close("m:nary"); +} + fn node(w: &mut W, n: &MNode, depth: usize) { match n { MNode::Run(r) => run(w, r), @@ -235,33 +296,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { arg(w, "m:e", e, depth); w.close("m:rad"); } - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { - w.open("m:nary", &[]); - w.open("m:naryPr", &[]); - if *chr != '∫' { - char_prop(w, "m:chr", Some(*chr)); - } - match lim_loc { - Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), - Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), - None => {} - } - if *grow { - val(w, "m:grow", "1"); - } - if *sub_hide { - val(w, "m:subHide", "1"); - } - if *sup_hide { - val(w, "m:supHide", "1"); - } - ctrl(w); - w.close("m:naryPr"); - arg(w, "m:sub", sub, depth); - arg(w, "m:sup", sup, depth); - arg(w, "m:e", e, depth); - w.close("m:nary"); - } + MNode::Nary { .. } => write_nary(w, n, depth), MNode::Delim { beg, end, sep, grow, shp_match, elems } => { w.open("m:d", &[]); w.open("m:dPr", &[]); @@ -375,36 +410,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { } w.close("m:eqArr"); } - MNode::Matrix { rows, col_jc } => { - let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); - w.open("m:m", &[]); - w.open("m:mPr", &[]); - w.open("m:mcs", &[]); - for c in 0..cols { - let jc = match col_jc.get(c).copied().unwrap_or_default() { - ColJc::Center => "center", - ColJc::Left => "left", - ColJc::Right => "right", - }; - w.open("m:mc", &[]); - w.open("m:mcPr", &[]); - val(w, "m:count", "1"); - val(w, "m:mcJc", jc); - w.close("m:mcPr"); - w.close("m:mc"); - } - w.close("m:mcs"); - ctrl(w); - w.close("m:mPr"); - for r in rows { - w.open("m:mr", &[]); - for c in 0..cols { - arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); - } - w.close("m:mr"); - } - w.close("m:m"); - } + MNode::Matrix { .. } => write_matrix(w, n, depth), MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { w.open("m:phant", &[]); w.open("m:phantPr", &[]); diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index 05adf217cc..39c2e58d24 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -3,7 +3,10 @@ use super::settings::{ ANONYMOUS_INITIALS, annotation_author, app_xml, core_xml, note_id_base, notes_part, settings_xml, theme_xml, write_comment_ids, }; use super::styles::{numbering_xml, styles_xml}; -use super::*; +use super::{ + BTreeMap, Blocks, CT_WML, Document, DocxError, Flavor, HashMap, MAX_VBA_RELATED, PartRels, VBA_PROJECT_PART, VBA_RELATED, W, Writer, embed, + glossary, rt, settings_merge, watermark, xml, +}; /// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name /// without the leading `/`, bytes). @@ -60,6 +63,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = Vec::new(); let mut rels = PartRels::default(); let mut root = PartRels::default(); + let ns = xml::body_ns(); + let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); // Comments get paragraph ids before any story is written. let mut comment_paras: Vec<(u32, String)> = Vec::new(); @@ -73,41 +78,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); - w.open("w:document", &ns_refs); - let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); - if pic.is_some() || doc.settings.page_color.is_some() { - let color = doc.settings.page_color.map(|c| c.hex()); - let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); - w.open("w:background", &attrs); - if let Some(p) = pic - && let Some(file) = wr.media_files.get(&p.media) - { - let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); - wr.used_media.insert(p.media.clone()); - w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); - let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; - if !p.fill_type.is_empty() { - fa.push(("type", &p.fill_type)); - } - if !p.title.is_empty() { - fa.push(("o:title", &p.title)); - } - w.empty("v:fill", &fa); - w.close("v:background"); - } - w.close("w:background"); - } - w.open("w:body", &[]); - wr.blocks(&mut w, &doc.body, &mut rels, true, 0); - let last = doc.last_section.clone(); - wr.sectpr(&mut w, &last, &mut rels); - w.close("w:body"); - w.close("w:document"); - let body = w.into_bytes(); + let body = write_document_body(doc, &mut wr, &mut rels, &ns_refs); // Headers and footers (discovered while writing sections). let mut done = 0; @@ -150,126 +121,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - if blocks.is_empty() { - w.empty("w:p", &[]); - } else { - wr.blocks(&mut w, blocks, &mut prels, false, 0); - } - w.close(tag); - } - None if kind != "continuationNotice" => { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - w.open("w:p", &[]); - w.open("w:pPr", &[]); - w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); - w.close("w:pPr"); - w.open("w:r", &[]); - w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); - w.close("w:r"); - w.close("w:p"); - w.close(tag); - } - None => {} - } - } - let base = note_id_base(doc, foot); - let mut k = 0; - while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { - let nid = (k + base).to_string(); - k += 1; - w.open(tag, &[("w:id", &nid)]); - let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); - w.close(tag); - } - w.close(root); - let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; - rels.add(kind, file, false); - push_part( - &mut entries, - &mut overrides, - &format!("word/{file}"), - w.into_bytes(), - &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), - prels, - ); - } + write_notes(doc, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); - // Comments. - if !doc.comments.is_empty() { - let mut prels = PartRels::default(); - let mut w = W::new(); - w.open("w:comments", &ns_refs); - for (cid, c) in &doc.comments { - let id = cid.to_string(); - let author = annotation_author(doc, &c.author); - let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; - if !c.date.is_empty() && !doc.settings.remove_date_time { - a.push(("w:date", c.date.as_str())); - } - if doc.settings.remove_personal_info { - a.push(("w:initials", ANONYMOUS_INITIALS)); - } else if !c.initials.is_empty() { - a.push(("w:initials", c.initials.as_str())); - } - w.open("w:comment", &a); - let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.blocks(&mut w, &blocks, &mut prels, false, 0); - w.close("w:comment"); - } - w.close("w:comments"); - rels.add(rt::COMMENTS, "comments.xml", false); - push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); - - let mut w = W::new(); - w.open( - "w15:commentsEx", - &[ - ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), - ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), - ], - ); - let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); - for (cid, c) in &doc.comments { - let Some(pid) = pid_of(*cid) else { continue }; - let done = if c.resolved { "1" } else { "0" }; - match c.parent.and_then(pid_of) { - Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), - None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), - } - } - w.close("w15:commentsEx"); - rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); - push_part( - &mut entries, - &mut overrides, - "word/commentsExtended.xml", - w.into_bytes(), - &format!("{CT_WML}commentsExtended+xml"), - PartRels::default(), - ); - write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels); - } + write_comments(doc, &comment_paras, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); rels.add(rt::STYLES, "styles.xml", false); push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default()); @@ -425,6 +279,174 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result, rels: &mut PartRels, ns_refs: &[(&str, &str)]) -> Vec { + let mut w = W::new(); + w.open("w:document", ns_refs); + let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); + if pic.is_some() || doc.settings.page_color.is_some() { + let color = doc.settings.page_color.map(|c| c.hex()); + let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); + w.open("w:background", &attrs); + if let Some(p) = pic + && let Some(file) = wr.media_files.get(&p.media) + { + let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); + wr.used_media.insert(p.media.clone()); + w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); + let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; + if !p.fill_type.is_empty() { + fa.push(("type", &p.fill_type)); + } + if !p.title.is_empty() { + fa.push(("o:title", &p.title)); + } + w.empty("v:fill", &fa); + w.close("v:background"); + } + w.close("w:background"); + } + w.open("w:body", &[]); + wr.blocks(&mut w, &doc.body, rels, true, 0); + let last = doc.last_section.clone(); + wr.sectpr(&mut w, &last, rels); + w.close("w:body"); + w.close("w:document"); + w.into_bytes() +} + +fn write_notes( + doc: &Document, + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + for foot in [true, false] { + let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators }; + if !notes_part(doc, wr, foot) { + continue; + } + let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") }; + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open(root, ns_refs); + // The separators and continuation notice: the document's own, else the defaults (no + // notice). Word numbers them -1, 0 and 1. + for (id, kind, own) in [ + ("-1", "separator", &seps.separator), + ("0", "continuationSeparator", &seps.continuation_separator), + ("1", "continuationNotice", &seps.continuation_notice), + ] { + match own { + Some(blocks) => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + if blocks.is_empty() { + w.empty("w:p", &[]); + } else { + wr.blocks(&mut w, blocks, &mut prels, false, 0); + } + w.close(tag); + } + None if kind != "continuationNotice" => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + w.open("w:p", &[]); + w.open("w:pPr", &[]); + w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); + w.close("w:pPr"); + w.open("w:r", &[]); + w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); + w.close("w:r"); + w.close("w:p"); + w.close(tag); + } + None => {} + } + } + let base = note_id_base(doc, foot); + let mut k = 0; + while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { + let nid = (k + base).to_string(); + k += 1; + w.open(tag, &[("w:id", &nid)]); + let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); + w.close(tag); + } + w.close(root); + let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; + rels.add(kind, file, false); + push_part( + entries, + overrides, + &format!("word/{file}"), + w.into_bytes(), + &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), + prels, + ); + } +} + +fn write_comments( + doc: &Document, + comment_paras: &[(u32, String)], + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + if !doc.comments.is_empty() { + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open("w:comments", ns_refs); + for (cid, c) in &doc.comments { + let id = cid.to_string(); + let author = annotation_author(doc, &c.author); + let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; + if !c.date.is_empty() && !doc.settings.remove_date_time { + a.push(("w:date", c.date.as_str())); + } + if doc.settings.remove_personal_info { + a.push(("w:initials", ANONYMOUS_INITIALS)); + } else if !c.initials.is_empty() { + a.push(("w:initials", c.initials.as_str())); + } + w.open("w:comment", &a); + let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.blocks(&mut w, &blocks, &mut prels, false, 0); + w.close("w:comment"); + } + w.close("w:comments"); + rels.add(rt::COMMENTS, "comments.xml", false); + push_part(entries, overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); + + let mut w = W::new(); + w.open( + "w15:commentsEx", + &[ + ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), + ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), + ], + ); + // Assigned in BTreeMap comment-ID order above, including gaps for missing parts. + let pid_of = + |cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str()); + for (cid, c) in &doc.comments { + let Some(pid) = pid_of(*cid) else { continue }; + let done = if c.resolved { "1" } else { "0" }; + match c.parent.and_then(pid_of) { + Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:paraIdParent", parent), ("w15:done", done)]), + None => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:done", done)]), + } + } + w.close("w15:commentsEx"); + rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); + push_part(entries, overrides, "word/commentsExtended.xml", w.into_bytes(), &format!("{CT_WML}commentsExtended+xml"), PartRels::default()); + write_comment_ids(doc, comment_paras, entries, overrides, rels); + } +} + pub(super) fn push_part( entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index aba9fdf464..57400cd059 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { toggle(w, "w:emboss", c.emboss); toggle(w, "w:imprint", c.engrave); toggle(w, "w:noProof", c.no_proof); + toggle(w, "w:snapToGrid", c.snap_to_grid); toggle(w, "w:vanish", c.hidden); + toggle(w, "w:webHidden", c.web_hidden); match c.color { Some(TextColor::Auto) => w.val("w:color", "auto"), Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()), @@ -253,6 +255,40 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:keepNext", p.keep_next); toggle(w, "w:keepLines", p.keep_lines); toggle(w, "w:pageBreakBefore", p.page_break_before); + write_frame(w, p, framed); + toggle(w, "w:widowControl", p.widow_control); + if let Some(nr) = p.numbering { + w.open("w:numPr", &[]); + w.val("w:ilvl", &n(nr.level.min(8) as i64)); + w.val("w:numId", &n(nr.num as i64)); + if let Some(a) = num_change { + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.empty("w:numberingChange", &refs); + } + w.close("w:numPr"); + } + toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); + if let Some(b) = &p.borders { + borders(w, "w:pBdr", b, Some("w:between"), &[]); + } + if let Some(s) = p.shading { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); + } + write_tabs(w, p); + write_text_flow(w, p); + write_spacing(w, p); + write_indentation(w, p); + toggle(w, "w:contextualSpacing", p.contextual_spacing); + toggle(w, "w:mirrorIndents", p.mirror_indents); + if let Some(a) = p.align { + w.val("w:jc", align_val(a)); + } + if let Some(l) = p.outline_level { + w.val("w:outlineLvl", &n(l.min(9) as i64)); + } +} + +fn write_frame(w: &mut W, p: &ParaProps, framed: bool) { if framed && let Some(lines) = p.drop_cap.filter(|l| *l > 0) { // ECMA-376 §17.3.1.11: the drop cap's frame — dropped or in the margin, the lines it // spans and its distance from the text (`w:hSpace`, twips). @@ -295,24 +331,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:framePr", &a); } - toggle(w, "w:widowControl", p.widow_control); - if let Some(nr) = p.numbering { - w.open("w:numPr", &[]); - w.val("w:ilvl", &n(nr.level.min(8) as i64)); - w.val("w:numId", &n(nr.num as i64)); - if let Some(a) = num_change { - let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); - w.empty("w:numberingChange", &refs); - } - w.close("w:numPr"); - } - toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); - if let Some(b) = &p.borders { - borders(w, "w:pBdr", b, Some("w:between"), &[]); - } - if let Some(s) = p.shading { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); - } +} + +fn write_tabs(w: &mut W, p: &ParaProps) { if let Some(tabs) = &p.tabs && !tabs.is_empty() { @@ -334,6 +355,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.close("w:tabs"); } +} + +fn write_text_flow(w: &mut W, p: &ParaProps) { toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens); toggle(w, "w:kinsoku", p.kinsoku); toggle(w, "w:wordWrap", p.word_wrap); @@ -343,6 +367,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:autoSpaceDN", p.auto_space_dn); toggle(w, "w:bidi", p.bidi); toggle(w, "w:snapToGrid", p.snap_to_grid); +} + +fn write_spacing(w: &mut W, p: &ParaProps) { if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() @@ -375,6 +402,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:spacing", &a); } +} + +fn write_indentation(w: &mut W, p: &ParaProps) { let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]; if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) { let l = p.indent_left.map(twips); @@ -404,14 +434,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:ind", &a); } - toggle(w, "w:contextualSpacing", p.contextual_spacing); - toggle(w, "w:mirrorIndents", p.mirror_indents); - if let Some(a) = p.align { - w.val("w:jc", align_val(a)); - } - if let Some(l) = p.outline_level { - w.val("w:outlineLvl", &n(l.min(9) as i64)); - } } pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) { diff --git a/crates/docx/src/write/settings.rs b/crates/docx/src/write/settings.rs index 4359055ba5..1c7d20e53b 100644 --- a/crates/docx/src/write/settings.rs +++ b/crates/docx/src/write/settings.rs @@ -1,5 +1,5 @@ use super::parts::push_part; -use super::*; +use super::{CT_WML, Document, PartRels, W, Writer, math, rt, story, xml}; /// The file id of the first footnote or endnote: after the separators (-1, 0) and the /// continuation notice (1), when the document has one. @@ -102,22 +102,41 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp } } w.val("w:characterSpacingControl", "doNotCompress"); + write_note_settings(&mut w, s, s_notes, footnotes, endnotes); + write_compatibility_settings(&mut w, doc); + if let Some(m) = &s.math { + math::math_pr(&mut w, m); + } + w.close("w:settings"); + w.into_bytes() +} + +fn write_note_settings( + w: &mut W, + settings: &wordcraft_doc::Settings, + separators: (&wordcraft_doc::NoteSeparators, &wordcraft_doc::NoteSeparators), + footnotes: bool, + endnotes: bool, +) { for (tag, fmt, pr, used, el) in [ - ("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"), - ("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"), + ("w:footnotePr", settings.footnote_format, &settings.footnote_pr, footnotes, "w:footnote"), + ("w:endnotePr", settings.endnote_format, &settings.endnote_pr, endnotes, "w:endnote"), ] { w.open(tag, &[]); - story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); + story::note_pr_inner(w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); if used { w.empty(el, &[("w:id", "-1")]); w.empty(el, &[("w:id", "0")]); - let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 }; + let seps = if el == "w:footnote" { separators.0 } else { separators.1 }; if seps.continuation_notice.is_some() { w.empty(el, &[("w:id", "1")]); } } w.close(tag); } +} + +fn write_compatibility_settings(w: &mut W, doc: &Document) { w.open("w:compat", &[]); // CT_Compat: the older switches come before the `w:compatSetting`s. if doc.settings.do_not_expand_shift_return { @@ -126,17 +145,12 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp if doc.settings.sum_paragraph_spacing { w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); } - if s.allow_space_of_same_style_in_table { + if doc.settings.allow_space_of_same_style_in_table { w.empty("w:allowSpaceOfSameStyleInTable", &[]); } let mode = doc.settings.compat_mode.clamp(11, 15).to_string(); w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]); w.close("w:compat"); - if let Some(m) = &s.math { - math::math_pr(&mut w, m); - } - w.close("w:settings"); - w.into_bytes() } /// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0? @@ -158,6 +172,8 @@ pub(super) fn write_comment_ids( if comment_paras.is_empty() { return; } + // Keep the existing exact-string reservation rule, including comments without a written part. + let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect(); let mut used: std::collections::HashSet = std::collections::HashSet::new(); let mut next = 0x1000_0000u32; let mut rows: Vec<(String, String, Option)> = Vec::new(); @@ -169,7 +185,7 @@ pub(super) fn write_comment_ids( None => loop { next = next.saturating_add(1); let d = format!("{next:08X}"); - if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) { + if !used.contains(&d) && !reserved.contains(d.as_str()) { break d; } }, diff --git a/crates/docx/src/write/story/blocks.rs b/crates/docx/src/write/story/blocks.rs index f85995a831..bdb2fb5427 100644 --- a/crates/docx/src/write/story/blocks.rs +++ b/crates/docx/src/write/story/blocks.rs @@ -1,5 +1,5 @@ use super::toc::written_toc_span; -use super::*; +use super::{Block, Blocks, CharProps, MAX_DEPTH, PartRels, Table, W, Writer, tblpr, tcpr, trpr, twips}; impl Writer<'_> { fn write_trailing_para(&mut self, w: &mut W, depth: usize) { diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 0fa653bb05..1c819fa66a 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -1,5 +1,16 @@ use super::object::ink_name; -use super::*; +use super::{ + AltText, Anchor, Fill, Float, GradientFlip, GradientKind, InlineObject, PartRels, PathShape, Rgb, ShapeEffects, ShapeKind, W, Wrap, Writer, emu, + n, rt, +}; + +pub(super) struct DrawingInfo<'a> { + pub float: &'a Float, + pub size: (f32, f32), + pub docpr: &'a str, + pub name: &'a str, + pub alt: &'a str, +} impl Writer<'_> { /// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0). @@ -198,28 +209,19 @@ impl Writer<'_> { w.close("wps:wsp"); } - #[allow(clippy::too_many_arguments)] /// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place. - #[allow(clippy::too_many_arguments)] - pub(super) fn graphic_frame( - &mut self, - w: &mut W, - float: &Float, - (gw, gh): (f32, f32), - docpr: &str, - name: &str, - alt: &str, - graphic: &crate::xml::El, - ) { + pub(super) fn graphic_frame(&mut self, w: &mut W, info: DrawingInfo<'_>, graphic: &crate::xml::El) { + let inline = info.float.wrap == Wrap::Inline; w.open("w:drawing", &[]); - self.drawing_open(w, float, gw, gh, docpr, name, alt); + self.drawing_open(w, info); w.empty("wp:cNvGraphicFramePr", &[]); w.raw(&graphic.to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close(if inline { "wp:inline" } else { "wp:anchor" }); w.close("w:drawing"); } - pub(super) fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) { + pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) { + let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info; let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0))); if float.wrap == Wrap::Inline { w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]); diff --git a/crates/docx/src/write/story/field.rs b/crates/docx/src/write/story/field.rs index 7aecffc74e..b1eec8116e 100644 --- a/crates/docx/src/write/story/field.rs +++ b/crates/docx/src/write/story/field.rs @@ -1,8 +1,17 @@ use super::toc::is_toc; -use super::*; +use super::{CharProps, W, Writer, rpr}; + +pub(super) struct FieldData<'a> { + pub instr: &'a str, + pub result: &'a str, + pub locked: bool, + pub dirty: bool, + pub form: &'a str, +} impl Writer<'_> { - pub(super) fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) { + pub(super) fn field(&mut self, w: &mut W, field: FieldData<'_>, props: &CharProps) { + let FieldData { instr, result, locked, dirty, form } = field; if self.toc_hold_end && is_toc(instr) { // A TOC heading's field opens in the first entry and closes after the last (see `toc_span`). self.toc_hold_end = false; diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 0fc089f07d..340ca8fce3 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -1,7 +1,7 @@ -use super::drawing::{shape_name, spin_attrs, write_fill, xfrm_spin_only}; -use super::field::field_run; +use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only}; +use super::field::{FieldData, field_run}; use super::section::comment_reference_style; -use super::*; +use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr}; impl Writer<'_> { pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { @@ -56,7 +56,9 @@ impl Writer<'_> { None => crate::write::math::write_equation(w, linear, *display, math, color), } } - InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props), + InlineObject::Field { instr, result, locked, dirty, form, .. } => { + self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props) + } InlineObject::FieldStart { instr, locked, dirty } => { self.rev_open(w, props); self.field_start(w, instr, *locked, *dirty, "", props); @@ -111,223 +113,10 @@ impl Writer<'_> { } // Editable charts and SmartArt keep main's model writer; opaque graphics and native // ink keep their original source and related parts. - InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { - let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; - let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { - (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), - (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), - // Ink in a group member's form (no drawing of its own) can't stand alone: left - // out before its parts are queued, so no orphan part or relationship is written. - (None, None, Some(src)) if ink && !standalone_ink(src) => { - log::warn!("docx: group-member ink outside its group can't be written; it is left out"); - None - } - (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), - (None, None, None) => None, - }; - let Some(mut root) = root else { return }; - let direct_ink = ink && root.name == "w:contentPart"; - if ink && !direct_ink { - Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); - } - // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. - let alternate = (root.name == "mc:AlternateContent").then_some(&root); - let fallback = alternate.and_then(|e| e.child("mc:Fallback")); - let choice = alternate.and_then(|e| e.child("mc:Choice")); - let native = choice.and_then(|e| e.find("a:graphic")); - if alternate.is_some() && native.is_none() { - return; - } - // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. - let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - if direct_ink { - w.raw(&root.to_xml()); - } else { - if fallback.is_some() { - let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; - w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", requires)]); - } - let docpr = self.next_docpr(); - let name = match graphic.kind { - wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), - wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), - wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), - }; - let z = self.z; - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root)); - if let Some(fallback) = fallback { - w.close("mc:Choice"); - match picture { - Some(picture) => { - // Both branches are one object: same id and stacking order. - self.z = z; - w.open("mc:Fallback", &[]); - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture); - w.close("mc:Fallback"); - } - None => w.raw(&fallback.to_xml()), - } - w.close("mc:AlternateContent"); - } - } - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => { - // An OLE object read from a file: the object itself, at its current size. - if let Some(src) = ole.as_deref() - && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) - { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.raw(&obj); - w.close("w:r"); - self.rev_close(w, props); - return; - } - let Some(file) = self.media_files.get(media).cloned() else { return }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("Picture {docpr}"); - self.drawing_open(w, float, *iw, *ih, &docpr, &name, alt); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); - self.pic(w, o, &file, (0.0, 0.0), rels); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.shape_docpr(extra.id); - // Ink is told apart by its name (and its pen), which reading looks for. - let name = shape_name(*kind, freeform.as_deref(), &docpr); - // The effect extent leaves room for the shadow and glow. - let mut float = float.clone(); - for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { - *e = wordcraft_geom::finite(*e).max(fx); - } - // Tight and Through wrapping need a polygon: the shape's outline unless it has one. - if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { - float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) - .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) - .map(std::sync::Arc::new); - } - let float = &float; - self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); - self.wsp(w, o, None, (0.0, 0.0), rels, depth); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); - self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - let outer = match canvas { - Some(c) => { - // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then - // its members, at offsets in the canvas's own (unscaled) space. - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); - w.open("wpc:wpc", &[]); - w.open("wpc:bg", &[]); - write_fill(w, c.fill.as_ref()); - w.close("wpc:bg"); - w.open("wpc:whole", &[]); - match c.stroke { - Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), - None => w.open("a:ln", &[]), - } - write_fill(w, c.stroke.map(Fill::solid).as_ref()); - w.close("a:ln"); - w.close("wpc:whole"); - "wpc:wpc" - } - None => { - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); - // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` - // maps onto the group's `a:off`/`a:ext`. - w.open("wpg:wgp", &[]); - w.empty("wpg:cNvGrpSpPr", &[]); - w.open("wpg:grpSpPr", &[]); - w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); - w.empty("a:off", &[("x", "0"), ("y", "0")]); - w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); - w.empty("a:chOff", &[("x", "0"), ("y", "0")]); - w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); - w.close("a:xfrm"); - w.close("wpg:grpSpPr"); - "wpg:wgp" - } - }; - for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { - let at = (c.x, c.y); - match &c.obj { - InlineObject::Image { media, .. } => { - if let Some(file) = self.media_files.get(media).cloned() { - self.pic(w, &c.obj, &file, at, rels); - } - } - InlineObject::Shape { extra, .. } => { - let id = self.shape_docpr(extra.id); - self.wsp(w, &c.obj, Some(&id), at, rels, depth); - } - // Native ink kept from the file, with its fallback, at the member's place. - InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { - if let Some(src) = graphic.source.as_deref() - && let Some(mut root) = self.embedded_tree(src, rels, None) - { - Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); - w.raw(&root.to_xml()); - } - } - _ => {} - } - } - if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { - // The canvas schema has no transform of its own: keep its turn in our - // extension (Word draws it unturned, as it can't turn a canvas). - w.open("wpc:extLst", &[]); - w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); - xfrm_spin_only(w, float.spin()); - w.close("a:ext"); - w.close("wpc:extLst"); - } - w.close(outer); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } + InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth), + InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth), + InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth), + InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth), InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { // ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text). let t = if self.is_del(props) { "w:delText" } else { "w:t" }; @@ -385,6 +174,231 @@ impl Writer<'_> { } } } + + fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { return }; + let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; + let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { + (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), + (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), + // Ink in a group member's form (no drawing of its own) can't stand alone: left + // out before its parts are queued, so no orphan part or relationship is written. + (None, None, Some(src)) if ink && !standalone_ink(src) => { + log::warn!("docx: group-member ink outside its group can't be written; it is left out"); + None + } + (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), + (None, None, None) => None, + }; + let Some(mut root) = root else { return }; + let direct_ink = ink && root.name == "w:contentPart"; + if ink && !direct_ink { + Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); + } + // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. + let alternate = (root.name == "mc:AlternateContent").then_some(&root); + let fallback = alternate.and_then(|e| e.child("mc:Fallback")); + let choice = alternate.and_then(|e| e.child("mc:Choice")); + let native = choice.and_then(|e| e.find("a:graphic")); + if alternate.is_some() && native.is_none() { + return; + } + // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. + let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + if direct_ink { + w.raw(&root.to_xml()); + } else { + if fallback.is_some() { + let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", requires)]); + } + let docpr = self.next_docpr(); + let name = match graphic.kind { + wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), + wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), + }; + let z = self.z; + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root)); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + match picture { + Some(picture) => { + // Both branches are one object: same id and stacking order. + self.z = z; + w.open("mc:Fallback", &[]); + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture); + w.close("mc:Fallback"); + } + None => w.raw(&fallback.to_xml()), + } + w.close("mc:AlternateContent"); + } + } + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return }; + // An OLE object read from a file: the object itself, at its current size. + if let Some(src) = ole.as_deref() + && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) + { + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.raw(&obj); + w.close("w:r"); + self.rev_close(w, props); + return; + } + let Some(file) = self.media_files.get(media).cloned() else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("Picture {docpr}"); + self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); + self.pic(w, o, &file, (0.0, 0.0), rels); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.shape_docpr(extra.id); + // Ink is told apart by its name (and its pen), which reading looks for. + let name = shape_name(*kind, freeform.as_deref(), &docpr); + // The effect extent leaves room for the shadow and glow. + let mut float = float.clone(); + for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { + *e = wordcraft_geom::finite(*e).max(fx); + } + // Tight and Through wrapping need a polygon: the shape's outline unless it has one. + if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { + float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) + .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) + .map(std::sync::Arc::new); + } + let float = &float; + self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); + self.wsp(w, o, None, (0.0, 0.0), rels, depth); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); + self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + let outer = match canvas { + Some(c) => { + // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then + // its members, at offsets in the canvas's own (unscaled) space. + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); + w.open("wpc:wpc", &[]); + w.open("wpc:bg", &[]); + write_fill(w, c.fill.as_ref()); + w.close("wpc:bg"); + w.open("wpc:whole", &[]); + match c.stroke { + Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), + None => w.open("a:ln", &[]), + } + write_fill(w, c.stroke.map(Fill::solid).as_ref()); + w.close("a:ln"); + w.close("wpc:whole"); + "wpc:wpc" + } + None => { + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); + // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` + // maps onto the group's `a:off`/`a:ext`. + w.open("wpg:wgp", &[]); + w.empty("wpg:cNvGrpSpPr", &[]); + w.open("wpg:grpSpPr", &[]); + w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); + w.empty("a:off", &[("x", "0"), ("y", "0")]); + w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); + w.empty("a:chOff", &[("x", "0"), ("y", "0")]); + w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); + w.close("a:xfrm"); + w.close("wpg:grpSpPr"); + "wpg:wgp" + } + }; + for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { + let at = (c.x, c.y); + match &c.obj { + InlineObject::Image { media, .. } => { + if let Some(file) = self.media_files.get(media).cloned() { + self.pic(w, &c.obj, &file, at, rels); + } + } + InlineObject::Shape { extra, .. } => { + let id = self.shape_docpr(extra.id); + self.wsp(w, &c.obj, Some(&id), at, rels, depth); + } + // Native ink kept from the file, with its fallback, at the member's place. + InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { + if let Some(src) = graphic.source.as_deref() + && let Some(mut root) = self.embedded_tree(src, rels, None) + { + Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); + w.raw(&root.to_xml()); + } + } + _ => {} + } + } + if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { + // The canvas schema has no transform of its own: keep its turn in our + // extension (Word draws it unturned, as it can't turn a canvas). + w.open("wpc:extLst", &[]); + w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); + xfrm_spin_only(w, float.spin()); + w.close("a:ext"); + w.close("wpc:extLst"); + } + w.close(outer); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } } pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str { diff --git a/crates/docx/src/write/story/para.rs b/crates/docx/src/write/story/para.rs index f8c999df70..712a031993 100644 --- a/crates/docx/src/write/story/para.rs +++ b/crates/docx/src/write/story/para.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + ChangeAttrs, CharProps, InlineObject, Paragraph, PartRels, Piece, PropChange, RevisionKind, W, Writer, open_change, ppr_inner, rpr, rpr_inner, rt, +}; impl Writer<'_> { pub(super) fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) { diff --git a/crates/docx/src/write/story/section.rs b/crates/docx/src/write/story/section.rs index fdf91ccf6e..e9712ddcc5 100644 --- a/crates/docx/src/write/story/section.rs +++ b/crates/docx/src/write/story/section.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LineNumberRestart, NoteProps, PartRels, SectionProps, SectionStart, W, Writer, borders, n, open_change, twips}; impl Writer<'_> { /// The section's headers, plus the header holding only the watermark in each slot the diff --git a/crates/docx/src/write/story/toc.rs b/crates/docx/src/write/story/toc.rs index 7ecd76b430..47c1041b75 100644 --- a/crates/docx/src/write/story/toc.rs +++ b/crates/docx/src/write/story/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, InlineObject, W, Writer}; impl Writer<'_> { pub(super) fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) { diff --git a/crates/docx/src/write/styles.rs b/crates/docx/src/write/styles.rs index 31a3246061..e750ba32e0 100644 --- a/crates/docx/src/write/styles.rs +++ b/crates/docx/src/write/styles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Level, LevelSuffix, ParaProps, Style, StyleKind, TablePart, VAlign, W, props, xml}; /// Built-in names Word keeps in lower case in styles.xml. fn file_style_name(name: &str) -> String { diff --git a/crates/docx/tests/it/citations.rs b/crates/docx/tests/it/citations.rs index 344ad9f67b..0c5fadd923 100644 --- a/crates/docx/tests/it/citations.rs +++ b/crates/docx/tests/it/citations.rs @@ -155,6 +155,38 @@ fn zotero_fields_and_prefs_round_trip() { assert_eq!(read(&write(&d2).unwrap()).unwrap().custom_props, d.custom_props); } +#[test] +fn custom_property_bookmark_links_survive_saves_and_value_edits() { + let props = r#" +Original value +Ω +17 +"#; + let doc = format!( + r#"{}{}"#, + t("Original value"), + t("Ω") + ); + let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", props.as_bytes())]); + let mut d = read(&bytes).unwrap(); + for value in ["Original value", "Edited value"] { + d.set_custom_prop("linked", value); + let before = d.custom_props.clone(); + for _ in 0..2 { + let out = write(&d).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut z.by_name("docProps/custom.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains(r#"linkTarget="OriginalBookmark""#), "{xml}"); + assert!(xml.contains(r#"linkTarget="RawBookmark""#), "{xml}"); + assert_eq!(xml.matches("linkTarget=").count(), 2, "unlinked properties stay unlinked"); + d = read(&out).unwrap(); + assert_eq!(d.custom_props, before); + assert_eq!(d.custom_prop("Linked"), Some(value)); + } + } +} + #[test] fn unterminated_and_resultless_addin_fields() { // A field left open at the end of the document, and one with no result at all. diff --git a/crates/docx/tests/it/comment_save_cost.rs b/crates/docx/tests/it/comment_save_cost.rs new file mode 100644 index 0000000000..2716578b29 --- /dev/null +++ b/crates/docx/tests/it/comment_save_cost.rs @@ -0,0 +1,55 @@ +//! Saving large comment threads must not search all comments for each emitted ID. + +use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block}; + +fn comments(n: u32, imported: bool) -> Document { + let mut d = Document::from_text("Original comment save fixture."); + for i in 0..n { + // Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order. + let id = i * 3 + 7; + d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() }); + d.comments.insert( + id, + Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() }, + ); + } + d +} + +fn cpu_cost(mut f: impl FnMut()) -> f64 { + let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::().ok(); + let (wall, before) = (std::time::Instant::now(), ran()); + f(); + match (before, ran()) { + (Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9, + _ => wall.elapsed().as_secs_f64(), + } +} + +#[test] +fn many_comments_save_without_quadratic_id_searches() { + for imported in [true, false] { + let small = comments(4_000, imported); + let big = comments(32_000, imported); + let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap())); + save(&small); + save(&big); + let mut ratios = Vec::new(); + // Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere + // use elapsed time; retry ratios far from linear before declaring a regression. + for _ in 0..3 { + let (mut s, mut b) = (f64::INFINITY, f64::INFINITY); + for _ in 0..3 { + s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0); + b = b.min(cpu_cost(|| save(&big))); + } + let ratio = b / s.max(1e-9); + eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}"); + if ratio < 24.0 { + break; + } + ratios.push(ratio); + } + assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches"); + } +} diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs index 7aab1415ed..63b79c4c4f 100644 --- a/crates/docx/tests/it/content_controls.rs +++ b/crates/docx/tests/it/content_controls.rs @@ -109,7 +109,13 @@ fn by_id(d: &Document, id: i64) -> ContentControl { #[test] fn every_kind_reads_with_its_properties() { let d = read(&form()).unwrap(); - let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap(); + assert_intro_control(&d); + assert_control_kinds(&d); + assert_table_controls(&d); +} + +fn assert_intro_control(d: &Document) { + let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap(); assert!(intro.block && intro.showing_placeholder); assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked)); assert_eq!(intro.placeholder, "DefaultPlaceholder_1"); @@ -119,13 +125,15 @@ fn every_kind_reads_with_its_properties() { let ranges = d.control_ranges(StoryRef::Body); let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap(); assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph"); +} - assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true }); - assert!(!by_id(&d, 7).block); +fn assert_control_kinds(d: &Document) { + assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true }); + assert!(!by_id(d, 7).block); assert!( - matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") + matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") ); - let dd = by_id(&d, 9); + let dd = by_id(d, 9); assert_eq!(dd.lock, ControlLock::ContentLocked); assert_eq!( dd.kind, @@ -135,14 +143,17 @@ fn every_kind_reads_with_its_properties() { } ); assert!( - matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") + matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") ); - assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept"); - assert_eq!(by_id(&d, 12).kind, ControlKind::RichText); - assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); - assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); - assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem); - assert_eq!(by_id(&d, 23).kind, ControlKind::Picture); + assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept"); + assert_eq!(by_id(d, 12).kind, ControlKind::RichText); + assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); + assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); + assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem); + assert_eq!(by_id(d, 23).kind, ControlKind::Picture); +} + +fn assert_table_controls(d: &Document) { // Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table. let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect(); assert_eq!(tables.len(), 2); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index a7c382660f..34b3bd0746 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() { } } +#[test] +fn zero_frame_keeps_normal_flow() { + const ROUND_TRIPS: usize = 2; + let zero = r#""#; + let sized = r#""#; + for frame in [zero, sized] { + let styles = format!( + r#"{frame} + {sized}"# + ); + let header = format!(r#"Header alphaHeader beta"#); + let footer = format!(r#"Footer delta"#); + let body = format!( + r#"Body one + {sized}Direct frame + Styled frame + {zero}Frame reset + "# + ); + let mut document = wordcraft_docx::read(&docx( + &body, + &[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")], + &[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)], + )) + .unwrap(); + for pass in 0..=ROUND_TRIPS { + let body = paras(&document); + let resolved = document.styles.resolve_para(&body[0].props); + assert!(resolved.frame.is_none(), "document defaults never create a frame"); + assert!(document.styles.default_para.frame.is_none()); + let reset = body[3].props.frame.as_ref().unwrap(); + assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving"); + assert_eq!(resolved.space_after, 8.0); + assert!(document.styles.resolve_para(&body[1].props).frame.is_some()); + assert!(document.styles.resolve_para(&body[2].props).frame.is_some()); + assert!(document.styles.resolve_para(&body[3].props).frame.is_none()); + for id in [document.last_section.headers.default, document.last_section.footers.default] { + for block in &document.parts.get(&id.unwrap()).unwrap().blocks { + let paragraph = block.as_para().unwrap(); + assert!(document.styles.resolve_para(¶graph.props).frame.is_none(), "defaults do not frame headers or footers"); + } + } + if pass == ROUND_TRIPS { + break; + } + document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap(); + } + } +} + +#[test] +fn frame_zero_side_is_automatic() { + for (attrs, width, height) in + [(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)] + { + let document = read_body(&format!(r#"Original automatic frame"#)); + let paragraph = paras(&document)[0]; + let frame = document.styles.resolve_para(¶graph.props).frame.unwrap(); + assert_eq!((frame.w, frame.h), (width, height)); + } +} + #[test] fn omitted_paragraph_defaults_can_be_edited() { let styles = @@ -495,6 +557,12 @@ fn styles_numbering_settings_notes_comments_from_parts() { ("docProps/core.xml", core.as_bytes()), ]); let d = wordcraft_docx::read(&bytes).unwrap(); + assert_imported_styles(&d); + assert_imported_numbering_and_settings(&d); + assert_imported_body_and_metadata(&d); +} + +fn assert_imported_styles(d: &Document) { // Styles: localized default id renamed to Normal; display names mapped. let h = d.styles.get("1").unwrap(); assert_eq!(h.name, "Heading 1"); @@ -511,6 +579,9 @@ fn styles_numbering_settings_notes_comments_from_parts() { assert_eq!(g.header_chr.bold, Some(true)); assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4)); assert_eq!(d.settings.major_font, "Major Face"); +} + +fn assert_imported_numbering_and_settings(d: &Document) { // Numbering. let lv = d.numbering.level(2, 0).unwrap(); assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right)); @@ -525,8 +596,11 @@ fn styles_numbering_settings_notes_comments_from_parts() { // A hostile start is clamped, an unknown position left out. let fp = d.settings.footnote_pr; assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage))); +} + +fn assert_imported_body_and_metadata(d: &Document) { // Body. - let p = paras(&d); + let p = paras(d); assert_eq!(p[0].props.style.as_deref(), Some("1")); assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 })); assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face")); diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 92e07a6514..d784375947 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -509,3 +509,53 @@ fn hostile_glossaries_never_panic() { let doc = read(two); assert_eq!(doc.glossary.len(), 1); } + +#[test] +fn a_private_save_escapes_the_attribute_values_it_rewrites() { + // A value read from single quotes holds a bare `"`; the privacy rewrite wrote it back inside + // double quotes unescaped, which is not XML (review of #3872). + let quoted = r#"Added clause"#; + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(quoted)), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + let name = doc.glossary.first().map(|e| e.name.clone()).expect("an entry"); + assert_eq!(name, r#"Say "hi" & "#); + let mut private = doc.clone(); + private.settings.remove_personal_info = true; + let saved = wordcraft_docx::write(&private).unwrap(); + let files = unzip(&saved); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(!g.contains("Quill Ramsey"), "{g}"); + let again = wordcraft_docx::read(&saved).unwrap(); + assert_eq!(again.glossary.first().map(|e| e.name.as_str()), Some(name.as_str()), "{g}"); +} + +#[test] +fn a_private_save_keeps_character_references_in_attribute_values_as_written() { + // ` ` and ` ` are a newline and a tab only as references; written as the raw + // characters they read back as spaces (review of #3872). + let entry = r#"Added clause"#; + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(entry)), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + let before = doc.glossary.first().map(|e| e.description.clone()).expect("an entry"); + assert_eq!(before, "line1\nline2\tend"); + let mut private = doc.clone(); + private.settings.remove_personal_info = true; + let saved = wordcraft_docx::write(&private).unwrap(); + let files = unzip(&saved); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(!g.contains("Quill Ramsey") && g.contains(r#"w:val="line1 line2 end""#), "{g}"); + let again = wordcraft_docx::read(&saved).unwrap(); + assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str())); +} diff --git a/crates/docx/tests/it/heading_levels.rs b/crates/docx/tests/it/heading_levels.rs new file mode 100644 index 0000000000..e32059dce8 --- /dev/null +++ b/crates/docx/tests/it/heading_levels.rs @@ -0,0 +1,57 @@ +//! #4696: Word's built-in heading styles have their outline level even when the file's +//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows, +//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever +//! their style ids) and body text for a custom style. +use std::io::Write; +use wordcraft_doc::Paragraph; + +const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +fn package(styles: &str, body: &str) -> Vec { + let root = r#""#; + let rels = r#""#; + let doc = format!(r#"{body}"#); + let styles = format!(r#"{styles}"#); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in + [("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())] + { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(body.as_bytes()).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn para(style: &str, text: &str) -> String { + format!(r#"{text}"#) +} + +fn levels(d: &wordcraft_doc::Document) -> Vec> { + d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect() +} + +#[test] +fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() { + let styles = r#" + + + + + + + + "#; + let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"] + .iter() + .map(|s| para(s, "x")) + .collect::(); + let d = wordcraft_docx::read(&package(styles, &body)).unwrap(); + // Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style, + // "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a + // level the file gives is kept. + let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None]; + assert_eq!(levels(&d), want); + // The level survives a save and reopen. + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(levels(&again), want); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index c3661f2c31..5cae0de3e1 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -3,6 +3,7 @@ mod bidi_runs; mod cell_margin_inheritance; mod char_units; mod citations; +mod comment_save_cost; mod complex_script_inheritance; mod content_control_save_cost; mod content_controls; @@ -13,14 +14,17 @@ mod east_asian_lang; mod east_asian_layout; mod fixtures; mod glossary; +mod heading_levels; mod legacy_forms; mod ligatures; mod malformed; +mod note_comments; mod passthrough; mod positional_tabs; mod privacy; mod roundtrip; mod row_property_exceptions; +mod run_props; mod section_form_protection; mod section_page_number_type; mod section_text_direction; diff --git a/crates/docx/tests/it/note_comments.rs b/crates/docx/tests/it/note_comments.rs new file mode 100644 index 0000000000..d9dfba865d --- /dev/null +++ b/crates/docx/tests/it/note_comments.rs @@ -0,0 +1,68 @@ +//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is +//! synthetic, written from ECMA-376 §17.13.4 and §17.11. + +use wordcraft_doc::para::{NoteKind, OBJ}; +use wordcraft_doc::{Document, InlineObject}; + +use crate::fixtures::zip; + +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 DOC_RELS: &str = r#""#; + +fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String { + format!( + r#" {text}"# + ) +} + +fn read_doc() -> Document { + let doc = format!( + r#"Body"# + ); + let footnotes = format!(r#"{}"#, note("footnote", "footnoteRef", 7, "Foot.")); + let endnotes = format!(r#"{}"#, note("endnote", "endnoteRef", 8, "End.")); + let comments = format!( + r#"On the footnoteOn the endnote"# + ); + let bytes = zip(&[ + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", DOC_RELS.as_bytes()), + ("word/footnotes.xml", footnotes.as_bytes()), + ("word/endnotes.xml", endnotes.as_bytes()), + ("word/comments.xml", comments.as_bytes()), + ]); + wordcraft_docx::read(&bytes).unwrap() +} + +/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id +/// against the comments already read, and was dropped when the notes came first. +#[test] +fn comment_ranges_inside_notes_are_kept() { + let d = read_doc(); + let body = d.body[0].as_para().unwrap(); + let refs: Vec<(NoteKind, u32)> = body + .objects + .iter() + .filter_map(|o| match o { + InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)), + _ => None, + }) + .collect(); + assert_eq!(refs.len(), 2, "{:?}", body.objects); + for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) { + let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap(); + let comment = match &p.objects[..] { + [InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }] + if *k == kind && *n == id && c == e => + { + *c + } + o => panic!("{kind:?} {id}: {o:?}"), + }; + assert_eq!(d.comments.get(&comment).unwrap().author, author); + assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3); + assert_eq!(p.plain_text(), text); + } +} diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 65d4e3495e..cedb8d12ac 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -403,3 +403,91 @@ fn a_part_reached_under_many_spellings_is_kept_and_written_once() { let written: usize = files.values().map(Vec::len).sum(); assert!(written < 4 * 64 * 1024, "{written} bytes written"); } + +#[test] +fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capitals() { + // Part names are case-insensitive: a package storing `WORD/FONTTABLE.XML` and its fonts in + // capitals, related in lower case, loaded no fonts, and a save dropped the table and every + // font (review of #3872). + let table = format!( + r#""# + ); + let table_rels = rels(&[("rId1", &format!("{REL}/font"), "fonts/font1.odttf")]); + let font_bytes = vec![9u8; 2048]; + let ct = CT.replace( + ")> = vec![ + ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")])), + ("[Content_Types].xml", ct.into_bytes()), + ( + "word/document.xml", + format!(r#"Body"#).into_bytes(), + ), + ("word/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/fontTable"), "fontTable.xml")])), + ]; + entries.push(("WORD/FONTTABLE.XML", table.into_bytes())); + entries.push(("WORD/_RELS/FONTTABLE.XML.RELS", table_rels)); + entries.push(("WORD/FONTS/FONT1.ODTTF", font_bytes.clone())); + let doc = wordcraft_docx::read(&zip(&entries)).unwrap(); + let fonts = wordcraft_docx::embedded_fonts(&doc); + assert_eq!(fonts.len(), 1, "the font loads"); + assert_eq!(fonts[0].data, font_bytes); + // And through a save and a reopen. + let saved = wordcraft_docx::write(&doc).unwrap(); + let files = unzip(&saved); + assert!(files.keys().any(|k| k.to_ascii_lowercase().ends_with("fonttable.xml")), "{:?}", files.keys()); + let again = wordcraft_docx::read(&saved).unwrap(); + let fonts = wordcraft_docx::embedded_fonts(&again); + assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys()); + assert_eq!(fonts[0].data, font_bytes); +} + +#[test] +fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() { + use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block}; + let mut doc = Document::from_text("Original thread fixture."); + // New IDs must skip the exact durable ID reserved by a later comment, even one whose + // missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned. + let rows = [ + (3, None, Some(90), false, true), + (11, Some("5a1b2c3d"), Some(90), true, true), + (25, Some("5A1B2C3D"), Some(999), false, true), + (90, Some("10000001"), None, true, true), + (999, Some("10000002"), None, false, false), + (u32::MAX, Some("not-hex"), Some(3), false, true), + ]; + for (id, durable, parent, resolved, has_part) in rows { + let part = id; + if has_part { + doc.parts.insert( + part, + Part { + kind: PartKind::Comment, + blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))], + ..Default::default() + }, + ); + } + doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() }); + } + let files = save(&doc); + let (ids, _) = written_ids(&files); + assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]); + let ex = text(&files, "word/commentsExtended.xml"); + assert_eq!(ex.matches("World Health Organization"), "{item_xml}"); + assert!(item_xml.contains("Harbor Guild, Inc."), "{item_xml}"); + assert!(!item_xml.contains("Rivera"), "the corporate author takes the person authors' place: {item_xml}"); + let back = wordcraft_docx::read(&bytes).expect("read"); + let got: Vec<(&str, &str, &str)> = back.sources.iter().map(|s| (s.corporate.as_str(), s.author.as_str(), s.editor.as_str())).collect(); + assert_eq!(got, [("World Health Organization", "", "Chadha, Shelly"), ("Harbor Guild, Inc.", "", "")]); +} + +fn assert_bibliography_parts(bytes: &[u8]) { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); assert!(zip.by_name("customXml/item1.xml").is_ok()); assert!(zip.by_name("customXml/itemProps1.xml").is_ok()); assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok()); @@ -2281,6 +2320,12 @@ fn bibliography_sources_round_trip() { let mut item_xml = String::new(); std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap(); + assert_bibliography_types(&item_xml); + assert_bibliography_people(&item_xml); + assert_bibliography_details(&item_xml); +} + +fn assert_bibliography_types(item_xml: &str) { assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}"); assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}"); assert!(item_xml.contains("Book"), "{item_xml}"); @@ -2288,12 +2333,18 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("InternetSite"), "{item_xml}"); assert!(item_xml.contains("BookSection"), "{item_xml}"); assert!(item_xml.contains("Misc"), "{item_xml}"); +} + +fn assert_bibliography_people(item_xml: &str) { assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("RiveraAlex"), "{item_xml}"); assert!(item_xml.contains("SmithJohnDavid"), "{item_xml}"); assert!(item_xml.contains("Solo"), "{item_xml}"); assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("JohnsonRobert"), "{item_xml}"); +} + +fn assert_bibliography_details(item_xml: &str) { assert!(item_xml.contains("2nd"), "{item_xml}"); assert!(item_xml.contains("10"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); @@ -2303,50 +2354,68 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("05"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); assert!(item_xml.contains("2024-05-15"), "{item_xml}"); +} - let back = wordcraft_docx::read(&bytes).expect("read"); +fn assert_bibliography_sources_round_trip(bytes: &[u8]) { + let back = wordcraft_docx::read(bytes).expect("read"); assert_eq!(back.sources.len(), 5); - assert_eq!(back.sources[0].tag, "Rivera2021"); - assert_eq!(back.sources[0].kind, "book"); - assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei"); - assert_eq!(back.sources[0].title, "Shared Spaces"); - assert_eq!(back.sources[0].year, "2021"); - assert_eq!(back.sources[0].publisher, "Harbor Books"); - assert_eq!(back.sources[0].city, "Portland"); - assert_eq!(back.sources[0].editor, "Johnson, Robert"); - assert_eq!(back.sources[0].edition, "2nd"); - assert_eq!(back.sources[0].month, "10"); - assert_eq!(back.sources[0].day, "15"); + assert_bibliography_source_0(&back.sources[0]); + assert_bibliography_source_1(&back.sources[1]); + assert_bibliography_source_2(&back.sources[2]); + assert_bibliography_source_3(&back.sources[3]); + assert_bibliography_source_4(&back.sources[4]); +} - assert_eq!(back.sources[1].tag, "Smith2023"); - assert_eq!(back.sources[1].kind, "article"); - assert_eq!(back.sources[1].author, "Smith, John David"); - assert_eq!(back.sources[1].title, "Modern Typography"); - assert_eq!(back.sources[1].journal, "Design Quarterly"); - assert_eq!(back.sources[1].volume, "12"); - assert_eq!(back.sources[1].pages, "45-60"); - assert_eq!(back.sources[1].url, "https://example.com/typo"); - assert_eq!(back.sources[1].issue, "4"); - assert_eq!(back.sources[1].doi, "10.1000/182"); +fn assert_bibliography_source_0(source: &Source) { + assert_eq!(source.tag, "Rivera2021"); + assert_eq!(source.kind, "book"); + assert_eq!(source.author, "Rivera, Alex; Chen, Mei"); + assert_eq!(source.title, "Shared Spaces"); + assert_eq!(source.year, "2021"); + assert_eq!(source.publisher, "Harbor Books"); + assert_eq!(source.city, "Portland"); + assert_eq!(source.editor, "Johnson, Robert"); + assert_eq!(source.edition, "2nd"); + assert_eq!(source.month, "10"); + assert_eq!(source.day, "15"); +} - assert_eq!(back.sources[2].tag, "WebSite2024"); - assert_eq!(back.sources[2].kind, "website"); - assert_eq!(back.sources[2].author, "Solo"); - assert_eq!(back.sources[2].title, "Open Fonts"); - assert_eq!(back.sources[2].url, "https://example.org"); - assert_eq!(back.sources[2].accessed, "2024-05-15"); +fn assert_bibliography_source_1(source: &Source) { + assert_eq!(source.tag, "Smith2023"); + assert_eq!(source.kind, "article"); + assert_eq!(source.author, "Smith, John David"); + assert_eq!(source.title, "Modern Typography"); + assert_eq!(source.journal, "Design Quarterly"); + assert_eq!(source.volume, "12"); + assert_eq!(source.pages, "45-60"); + assert_eq!(source.url, "https://example.com/typo"); + assert_eq!(source.issue, "4"); + assert_eq!(source.doi, "10.1000/182"); +} - assert_eq!(back.sources[3].tag, "Chapter2025"); - assert_eq!(back.sources[3].kind, "bookSection"); - assert_eq!(back.sources[3].author, "Lee, Harper"); - assert_eq!(back.sources[3].title, "Chapter One"); - assert_eq!(back.sources[3].journal, "Anthology of Prose"); - assert_eq!(back.sources[3].pages, "1-20"); +fn assert_bibliography_source_2(source: &Source) { + assert_eq!(source.tag, "WebSite2024"); + assert_eq!(source.kind, "website"); + assert_eq!(source.author, "Solo"); + assert_eq!(source.title, "Open Fonts"); + assert_eq!(source.url, "https://example.org"); + assert_eq!(source.accessed, "2024-05-15"); +} - assert_eq!(back.sources[4].tag, "Misc2026"); - assert_eq!(back.sources[4].kind, "other"); - assert_eq!(back.sources[4].title, "Archive Manuscript"); - assert_eq!(back.sources[4].city, "Oxford"); +fn assert_bibliography_source_3(source: &Source) { + assert_eq!(source.tag, "Chapter2025"); + assert_eq!(source.kind, "bookSection"); + assert_eq!(source.author, "Lee, Harper"); + assert_eq!(source.title, "Chapter One"); + assert_eq!(source.journal, "Anthology of Prose"); + assert_eq!(source.pages, "1-20"); +} + +fn assert_bibliography_source_4(source: &Source) { + assert_eq!(source.tag, "Misc2026"); + assert_eq!(source.kind, "other"); + assert_eq!(source.title, "Archive Manuscript"); + assert_eq!(source.city, "Oxford"); } #[test] diff --git a/crates/docx/tests/it/run_props.rs b/crates/docx/tests/it/run_props.rs new file mode 100644 index 0000000000..c203b26a05 --- /dev/null +++ b/crates/docx/tests/it/run_props.rs @@ -0,0 +1,74 @@ +//! Run and style properties that must survive repeated DOCX saves. + +use std::io::{Read, Write}; + +fn docx(body: &str, styles: &str) -> Vec { + let root = r#""#; + let rels = r#""#; + let document = format!( + r#"{body}"# + ); + let styles = format!(r#"{styles}"#); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, xml) in [ + ("_rels/.rels", root.to_string()), + ("word/document.xml", document), + ("word/_rels/document.xml.rels", rels.to_string()), + ("word/styles.xml", styles), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(xml.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() { + let styles = r#" + + + "#; + let body = r#" + + inherit + direct true + override + visible in print + absent + + "#; + let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap(); + let p = doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.runs[0].props.snap_to_grid, None); + assert_eq!(p.runs[0].props.web_hidden, None); + assert_eq!(p.runs[1].props.snap_to_grid, Some(true)); + assert_eq!(p.runs[1].props.web_hidden, Some(true)); + assert_eq!(p.runs[2].props.snap_to_grid, Some(false)); + assert_eq!(p.runs[2].props.web_hidden, Some(false)); + assert_eq!(p.runs[3].props.hidden, Some(true)); + assert_eq!(p.runs[3].props.web_hidden, None); + assert_eq!(p.runs[4].props.snap_to_grid, None); + assert_eq!(p.runs[4].props.web_hidden, None); + assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text"); + assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text"); + for pass in 0..2 { + let out = wordcraft_docx::write(&doc).unwrap(); + let style_xml = part(&out, "word/styles.xml"); + assert!(style_xml.contains(r#""#), "pass {pass}: {style_xml}"); + assert!(style_xml.contains(r#""#), "pass {pass}: {style_xml}"); + let document_xml = part(&out, "word/document.xml"); + assert!(document_xml.contains(r#""#), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(r#""#), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(""), "pass {pass}: {document_xml}"); + assert!(document_xml.contains(""), "pass {pass}: {document_xml}"); + assert!(document_xml.contains("visible in print")); + doc = wordcraft_docx::read(&out).unwrap(); + } +} diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 0000000000..318576a4be --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,64 @@ +//! Export timing: saves a long generated document (the built-in sample document repeated) through +//! the real save path, the way `file.save` / `file.export` write it, and prints wall times. +//! +//! `cargo run --release -p wordcraft-engine --example export_bench -- [ext] [copies] [runs] [out]` +//! +//! Defaults: `pdf`, 40 copies (about 100 pages), 7 runs. With `out` the last run's bytes are +//! written there, to compare the output of two builds. + +use std::sync::Arc; +use std::time::Instant; + +use wordcraft_engine::doc::props::CharProps; +use wordcraft_engine::doc::{Block, Document}; +use wordcraft_engine::{io, sample}; + +/// The sample document's body `copies` times, each paragraph ending in its copy number so no two +/// copies share text (as the chapters of a real long document don't). +fn long_document(copies: usize) -> Document { + let mut d = sample::sample_document(); + // A fixed creation date: the PDF's dates (and the ID derived from them) stay the same from run to run. + d.core.created = "2026-01-01T00:00:00Z".into(); + let one: Vec = d.body.iter().map(|b| (**b).clone()).collect(); + d.body.clear(); + for i in 0..copies { + for b in &one { + let mut b = b.clone(); + if let Block::Para(p) = &mut b { + let at = p.len(); + let _ = p.insert_text(at, &format!(" {i}"), &CharProps::default()); + } + d.body.push(Arc::new(b)); + } + } + d +} + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + let ext = args.first().map_or("pdf", String::as_str); + let copies = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(40); + let runs = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(7usize).max(1); + let doc = long_document(copies); + let name = format!("bench.{ext}"); + let mut ms = Vec::with_capacity(runs); + let mut bytes = Vec::new(); + for _ in 0..runs { + let t = Instant::now(); + bytes = io::save_bytes(&name, &doc)?; + ms.push(t.elapsed().as_secs_f64() * 1000.0); + } + let mut sorted = ms.clone(); + sorted.sort_by(f64::total_cmp); + let median = sorted.get(sorted.len() / 2).copied().unwrap_or_default(); + let min = sorted.first().copied().unwrap_or_default(); + println!( + "{ext}: {copies} copies, {} bytes, runs {:?} ms, median {median:.1} ms, min {min:.1} ms", + bytes.len(), + ms.iter().map(|m| m.round()).collect::>() + ); + if let Some(out) = args.get(3) { + std::fs::write(out, &bytes).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/citations/cross_refs.rs b/crates/engine/src/cmd/citations/cross_refs.rs index f465045779..265a73fabc 100644 --- a/crates/engine/src/cmd/citations/cross_refs.rs +++ b/crates/engine/src/cmd/citations/cross_refs.rs @@ -1,5 +1,5 @@ use super::generated::page_of; -use super::*; +use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p}; pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { let to = p::str(v, "to").unwrap_or("heading"); @@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option { (!label.is_empty() && label.chars().count() <= 40).then_some(label) } +/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a +/// style of its own (a `Table Caption` based on Caption) still is (#4411). +pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool { + p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption")) +} + /// The caption paragraphs of the body: (text, position, label). fn captions(s: &Session) -> Vec<(String, Pos, Option)> { s.doc .para_paths(StoryRef::Body) .into_iter() .filter_map(|path| { - let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?; + let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?; let text = p.plain_text(); let label = caption_label(p, &text); Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label)) @@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> { }; for path in s.doc.para_paths(StoryRef::Body) { if let Some(p) = s.doc.para(StoryRef::Body, &path) - && p.props.style.as_deref() == Some("Caption") + && is_caption(&s.doc, p) && p.plain_text().starts_with(label) { targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 })); diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index 2f9e580d62..364a6223be 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -1,8 +1,11 @@ -use super::cross_refs::caption_label; +use super::cross_refs::{caption_label, is_caption}; use super::render::entry; use super::sources::bib_order; use super::styles::style; -use super::*; +use super::{ + Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, + TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key, +}; /// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "