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..85a3f19518 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # 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/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: wordcraft app-dir: WordCraft + install-relative-path: Programs/ArtCraft/WordCraft + install-scope: perUser diff --git a/CLAUDE.md b/CLAUDE.md index 9f11dd0226..55b2767729 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. diff --git a/Cargo.lock b/Cargo.lock index b0b896f529..901e13678b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -6682,7 +6682,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", "sys-locale", @@ -6695,11 +6695,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 +6713,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.11.0" +version = "0.12.0" dependencies = [ "aes", "base64 0.23.1", @@ -6754,7 +6754,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6785,7 +6785,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "brotli-decompressor", "harfrust 0.13.3", @@ -6799,7 +6799,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chardetng", @@ -6817,7 +6817,7 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "serde", @@ -6825,7 +6825,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "js-sys", @@ -6845,7 +6845,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "serde_json", @@ -6857,7 +6857,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.11.0" +version = "0.12.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -6865,7 +6865,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-pdf", @@ -6886,7 +6886,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "miniz_oxide 0.9.1", @@ -6896,7 +6896,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "log", @@ -6910,7 +6910,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cfb", "chrono", @@ -6941,7 +6941,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -6960,7 +6960,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "proptest", @@ -7123,7 +7123,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..eca974d33b 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" @@ -96,9 +96,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 4592deb374..fba3768d21 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-25 · **Change:** 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-29 · **Change:** 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,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 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-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. @@ -645,6 +646,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 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-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | 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/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/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/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/document.rs b/crates/doc/src/document.rs index 318bfcc330..7d116858c2 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; 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/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..b8ef433c52 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")] @@ -447,24 +447,50 @@ 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_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_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_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_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/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..aec1bd70f8 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)] 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/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/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..55d01e8a49 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)] = &[ 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..ee343c8662 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() { 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..7cea957d46 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,172 @@ 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"), + ], + ); + 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(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..b67ee6b076 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -253,6 +253,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 +329,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 +353,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 +365,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 +400,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 +432,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..df4e77fa64 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? 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/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..5d9e128fba 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -495,6 +495,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 +517,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 +534,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/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 8ba6cc0d61..55a532834d 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -2262,9 +2262,13 @@ fn bibliography_sources_round_trip() { }); let bytes = wordcraft_docx::write(&d).expect("write"); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); - // Verify package-root customXml parts exist + assert_bibliography_parts(&bytes); + assert_bibliography_sources_round_trip(&bytes); +} + +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 +2285,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 +2298,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 +2319,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/engine/src/cmd/citations/cross_refs.rs b/crates/engine/src/cmd/citations/cross_refs.rs index f465045779..64967f0bd7 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, 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"); diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index 2f9e580d62..f0946e10b2 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -2,7 +2,10 @@ use super::cross_refs::caption_label; use super::render::entry; use super::sources::bib_order; use super::styles::style; -use super::*; +use super::{ + Block, CharProps, CmdError, CmdResult, 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 "