mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 01:01:12 +00:00
Merge upstream/main into docx-malformed-xml-is-an-error
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
768e1cb2fa
179 files changed
+5925
-525
No files matched your search
+13
-47
@@ -1,8 +1,7 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
|
||||
# only the next contributor's local run discovers.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -12,51 +11,18 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
|
||||
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
|
||||
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
@@ -2,19 +2,18 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "27 4 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "27 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
Generated
+20
-20
@@ -5140,7 +5140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -5174,7 +5174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -5187,11 +5187,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"proptest",
|
||||
@@ -5204,7 +5204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -5221,7 +5221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -5245,7 +5245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -5275,7 +5275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5288,7 +5288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -5306,14 +5306,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5333,7 +5333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5345,7 +5345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5353,7 +5353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -5374,7 +5374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5383,7 +5383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5397,7 +5397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-ui",
|
||||
@@ -5424,7 +5424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5443,7 +5443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5556,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -91,11 +91,16 @@ todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
# for every test binary and dependency would multiply the target dir size.
|
||||
# The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+37
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a seLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -136,6 +136,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document.
|
||||
- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison.
|
||||
- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982).
|
||||
- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663).
|
||||
- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637).
|
||||
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
|
||||
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
|
||||
|
||||
@@ -143,6 +148,9 @@ Four geometry-dependent layout controls request Source Sans 3 explicitly. Instal
|
||||
|
||||
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
- 2026-10-21: A word with no break opportunity in it no longer makes the layout quadratic (#3412): measuring it beside a floating object walked the whole paragraph for every line it takes, so a 1 MiB word took 30 s to lay out. The layout is linear now.
|
||||
- 2026-10-23: A relationship the markup names without the prefix is read as it wrote (#3411 follow-up): a producer that left the prefix out writes the relationship id as a plain `id`, and the reader resolves it the way it resolves the namespaced one, so the part it names is kept with the object that names it and the writer rewrites the attribute with the id the new package gives the part. An ordinary `id` — a VML shape's, a drawing's `wp:docPr`, a picture's `pic:cNvPr` — is the element's own and names no part.
|
||||
- 2026-10-21: A document with many relationships opens in the time its part takes (#3411): the reader looked for each relationship's id in the main document's part, once per relationship, so a 70 KB file with 1024 relationships and a 64 MiB part took 224 s to open. The ids are now the ones the markup names in a relationship attribute, taken from the main document the reader has already parsed, so an id the text only mentions is no longer a reference and the part it belongs to is no longer dropped on save. The open is linear now.
|
||||
- 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme
|
||||
- 2026-10-21: Table styles shade the same banded rows in Word and WordCraft: our banded styles (Grid Table 4…) and new table styles write their band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`), which Word otherwise reads as zero and leaves unbanded, and a file's style without them is unbanded here too (#3415).
|
||||
- 2026-10-21: Screen readers: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172). The
|
||||
@@ -151,6 +159,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899).
|
||||
- 2026-10-20: Headers and footers: closing them with Esc or Close Header and Footer puts the caret back where it was in the body, as Word does, instead of on page 1 (the view no longer jumps to the top of the document, #3331).
|
||||
- 2026-10-21: A document with many content controls saves in the time the controls take (#13): the writer looked for each paragraph's markers in the whole set of markers, once per paragraph, and compared every control with every other one to find the ones that clash, so a document with 32,000 controls took four seconds to save. Both are a lookup and a sweep now, and what is written does not change.
|
||||
- 2026-10-20: Restrict Editing: read only and comments-only protection grey out every edit in the ribbon when no exception lets the author edit, and tracked-changes protection greys out Accept and Reject, instead of buttons that look live and refuse when clicked.
|
||||
- 2026-10-20: Restrict Editing: with Filling in forms enforced, the Developer tab's content-control buttons and Properties grey out in the ribbon, their tooltips saying why, as in Word; before, they looked live and only refused when clicked.
|
||||
- 2026-10-20: The page tip shown while dragging the vertical scrollbar stays on one line; it broke into two or three lines ("Page 10 of" / "40") as the page number grew during the drag (#3341).
|
||||
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
|
||||
@@ -158,6 +167,17 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. |
|
||||
| 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back |
|
||||
| 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open |
|
||||
| 2026-10-22 | Restrict Editing: the CLI's `--author` uses the same protection check as `file.setAuthor`, and File › Options disables the User name field while the document is protected. |
|
||||
| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting |
|
||||
| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones |
|
||||
| 2026-10-17 | Ribbon hints' reasons for a greyed-out control are translated in every interface language; a test fails if a reason it finds in the command sources, or the "Open a document first" hint, has no translation (#2678). |
|
||||
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. |
|
||||
| 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. |
|
||||
| 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) |
|
||||
@@ -284,6 +304,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
|
||||
| 2026-10-18 | Legacy menu keys (#1697; not on macOS): Alt, V, U turns on Full Screen View, as in Word for Windows, along with 17 other Word 2003 menu sequences observed there (views, ruler, zoom, Select All, Find/Replace/Go To, Symbol, Picture, Font, Paragraph, Word Count, Insert Table). Hidden as in Word, with a hint under the tab row while a sequence waits |
|
||||
| 2026-10-18 | View › Focus now fills the screen (#1678): the document on black with a slim bar whose "⋯" drops the ribbon over the page. Full Screen View (`view.fullScreenView`) shows the pages alone with Search at the top and can be bound to any key in Customize Keyboard; the two turn each other off. F11's Full Screen (#980) still fills the screen with the whole window. Esc leaves either; Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon, and a shortcut of your own on Ctrl+F1 wins. Immersive Reader no longer switches Focus on |
|
||||
| 2026-10-15 | Selection bar mirrors for right-to-left paragraphs (the right margin) and works beside table rows: a click in the page margin beside a row selects the row, a drag selects rows, Shift+click extends by rows (a right-to-left table's bar is on the right); `table.selectAt {"extend"}`. Word's right-pointing arrow still missing |
|
||||
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
|
||||
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
|
||||
| 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) |
|
||||
@@ -572,6 +593,17 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
|
||||
| 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) |
|
||||
| 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) |
|
||||
| 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) |
|
||||
| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. |
|
||||
| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
|
||||
| 2026-10-21 | trivial | Progress log: a word with no break opportunity in it no longer makes the layout quadratic (#3412) |
|
||||
| 2026-10-20 | minor | The Document Inspector's removals are refused while the document tracks changes (they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back), and a version snapshot belongs to the document it was taken from |
|
||||
| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting |
|
||||
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
|
||||
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
|
||||
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
|
||||
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
|
||||
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
|
||||
@@ -592,6 +624,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) |
|
||||
| 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings |
|
||||
| 2026-10-20 | trivial | Progress log: closing the header returns to the body selection (#3331) |
|
||||
| 2026-10-20 | trivial | Progress log: read only and comments-only protection grey out edits |
|
||||
| 2026-10-20 | trivial | Progress log: forms protection greys out the Developer tab's control buttons and Properties |
|
||||
| 2026-10-20 | trivial | Progress log: the scrollbar page tip stays on one line (#3341) |
|
||||
| 2026-10-19 | trivial | Progress log: typing no longer stops after clicking Search (#2986) |
|
||||
@@ -642,6 +675,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) |
|
||||
| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
|
||||
| 2026-10-15 | trivial | Progress log: selection bar for right-to-left paragraphs and table rows |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
@@ -891,3 +925,5 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
|
||||
| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) |
|
||||
| 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) |
|
||||
| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) |
|
||||
| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) |
|
||||
@@ -180,6 +180,8 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
|
||||
/// The command also checks whether the open document is protected: this name determines which
|
||||
/// named editing exceptions the session can use, so the flag must not bypass that check.
|
||||
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
|
||||
}
|
||||
@@ -805,6 +807,31 @@ mod tests {
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.author.clone();
|
||||
assert!(set_author(&mut s, "Claude").is_err());
|
||||
assert_eq!(s.author, before);
|
||||
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_cannot_claim_a_named_exception_after_protection() {
|
||||
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(s.doc.perm_ranges().len(), 1);
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Named"})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
|
||||
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1127,7 +1154,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
|
||||
/// (as in `tests/non_utf8_args.rs`).
|
||||
/// (as in `tests/it/non_utf8_args.rs`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
//! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI
|
||||
//! quietly with exit 0, never with a panic (#508).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,9 @@
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert_fields;
|
||||
mod convert_region;
|
||||
mod help;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod non_utf8_args;
|
||||
mod picture_then_typing;
|
||||
mod text_tracked;
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
|
||||
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
|
||||
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
|
||||
#![cfg(all(unix, not(target_os = "macos")))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
File renamed without changes.
File renamed without changes.
@@ -258,7 +258,9 @@ mod tests {
|
||||
fn an_instant_cancel_retries_then_falls_back() {
|
||||
let quick = Duration::from_millis(200);
|
||||
let slow_cancel = || async {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
// The test polls this future by hand, with no async runtime, so a blocking sleep
|
||||
// models a slow portal response.
|
||||
std::thread::sleep(Duration::from_millis(250)); // NOSONAR
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,4 @@
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
mod non_utf8_args;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
@@ -175,3 +175,6 @@ display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
@@ -74,6 +74,8 @@ impl Counting {
|
||||
out.push(u);
|
||||
}
|
||||
}
|
||||
// Trailing zeros of a group are not a gap: the next group starts a new place.
|
||||
zero_pending = false;
|
||||
match gi {
|
||||
0 => out.extend(self.myriads.get(1)),
|
||||
1 => out.extend(self.myriads.first()),
|
||||
@@ -324,6 +326,17 @@ pub(crate) fn chosung(n: u32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chinese_no_zero_after_group_trailing_zeros() {
|
||||
assert_eq!(chinese_counting_thousand(201_000), "二十万一千");
|
||||
assert_eq!(chinese_counting_thousand(120_000), "十二万");
|
||||
assert_eq!(chinese_counting_thousand(100_100), "十万零一百");
|
||||
assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千");
|
||||
assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一");
|
||||
assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万");
|
||||
assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_counting_systems() {
|
||||
let c = |n| chinese_counting(n);
|
||||
|
||||
+240
-12
@@ -10,7 +10,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::graphic::PathSeg;
|
||||
use crate::para::{GroupChild, InlineObject, ShapeKind};
|
||||
use crate::para::{InlineObject, ShapeKind};
|
||||
use crate::wordart::TextWarp;
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
@@ -191,9 +191,16 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
|
||||
if at_end { (ux, uy) } else { (-ux, -uy) }
|
||||
}
|
||||
|
||||
/// Re-route the glued connectors among a group's members (they share the group's coordinate
|
||||
/// space). Returns whether any moved.
|
||||
pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
|
||||
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
|
||||
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
|
||||
/// fallback are unchanged.
|
||||
pub fn reroute_group(group: &mut InlineObject) -> bool {
|
||||
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
|
||||
let fin = wordcraft_geom::finite;
|
||||
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
|
||||
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
|
||||
.iter()
|
||||
.filter_map(|c| match &c.obj {
|
||||
@@ -215,9 +222,35 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0));
|
||||
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
|
||||
(Some(s), Some(e)) => (s, e, false),
|
||||
(s, e) => {
|
||||
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Rendering scales the rectangle before spinning it. Undo only the group scale
|
||||
// so route can rebuild an unrotated child frame without moving fallback ends.
|
||||
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
|
||||
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
|
||||
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
|
||||
let spin = float.spin();
|
||||
let s0 = spin.apply(cx, cy, x, y);
|
||||
let e0 = spin.apply(cx, cy, x + cw, y + ch);
|
||||
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
|
||||
}
|
||||
};
|
||||
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
|
||||
continue;
|
||||
}
|
||||
let f = route(s, e);
|
||||
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
|
||||
continue;
|
||||
}
|
||||
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
|
||||
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
|
||||
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
|
||||
continue;
|
||||
}
|
||||
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
|
||||
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
|
||||
continue;
|
||||
@@ -237,7 +270,7 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::para::Float;
|
||||
use crate::para::{Float, GroupChild};
|
||||
|
||||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
@@ -255,6 +288,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn group(children: Vec<GroupChild>) -> InlineObject {
|
||||
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_and_ends_are_inverse() {
|
||||
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
|
||||
@@ -275,6 +312,174 @@ mod tests {
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
|
||||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||||
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
|
||||
},
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_both_unresolved_ends() {
|
||||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let kids = vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
|
||||
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
|
||||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
|
||||
(*w, *h) = (400.0, 150.0);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
|
||||
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
|
||||
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.flip_h = free_start;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 3000.0, y: 10.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, .. } = &mut group else { panic!() };
|
||||
*w = 400.0;
|
||||
let visual_free = |group: &InlineObject| {
|
||||
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
|
||||
let ([x, y, w, h], obj) = rects[1];
|
||||
let spin = obj.frame().unwrap().2.spin();
|
||||
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
|
||||
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
|
||||
};
|
||||
let before = visual_free(&group);
|
||||
assert_eq!(before, (6080.0, 10.0));
|
||||
let moved = reroute_group(&mut group);
|
||||
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
|
||||
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
|
||||
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
|
||||
for dims in [
|
||||
[0.0, 300.0, 200.0, 300.0],
|
||||
[200.0, 0.0, 200.0, 300.0],
|
||||
[-200.0, 300.0, 200.0, 300.0],
|
||||
[f32::NAN, 300.0, 200.0, 300.0],
|
||||
[200.0, f32::INFINITY, 200.0, 300.0],
|
||||
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
|
||||
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
|
||||
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
|
||||
[200.0, 300.0, 0.0, 0.0],
|
||||
[200.0, 300.0, f32::NAN, f32::INFINITY],
|
||||
[200.0, 300.0, -200.0, -300.0],
|
||||
] {
|
||||
let mut obj =
|
||||
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
|
||||
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
|
||||
let moved = reroute_group(&mut group);
|
||||
// group_rects treats invalid child extents as unit scale; display scales that
|
||||
// cannot be inverted (or whose inverse overflows) must not change the connector.
|
||||
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
|
||||
}
|
||||
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_connectors_follow_their_shapes() {
|
||||
let a = ShapeExtra { id: 7, ..Default::default() };
|
||||
@@ -285,17 +490,40 @@ mod tests {
|
||||
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
|
||||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
|
||||
];
|
||||
assert!(reroute_group(&mut kids));
|
||||
// Fully resolved endpoints must still come from their sites, regardless of the
|
||||
// connector's old rotation and flips.
|
||||
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
float.flip_h = true;
|
||||
float.flip_v = true;
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
|
||||
let c = &kids[2];
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[2];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
|
||||
assert!(!reroute_group(&mut kids), "settled");
|
||||
assert!(!reroute_group(&mut group), "settled");
|
||||
// A fully resolved connector does not need an invertible group scale.
|
||||
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
|
||||
let mut scaled = group.clone();
|
||||
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
|
||||
(*w, *h) = (width, height);
|
||||
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
assert!(reroute_group(&mut scaled));
|
||||
let InlineObject::Group { children, .. } = &scaled else { panic!() };
|
||||
let c = &children[2];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
|
||||
assert!(!reroute_group(&mut scaled));
|
||||
}
|
||||
// A connection to a missing id or site keeps that end where it was.
|
||||
if let InlineObject::Shape { extra, .. } = &mut kids[2].obj {
|
||||
let InlineObject::Group { children, .. } = &mut group else { panic!() };
|
||||
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
|
||||
extra.end = Some(ConnEnd { id: 404, site: 0 });
|
||||
extra.start = Some(ConnEnd { id: 7, site: 99 });
|
||||
}
|
||||
assert!(!reroute_group(&mut kids));
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,9 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
|
||||
return None;
|
||||
}
|
||||
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
@@ -151,6 +154,15 @@ mod tests {
|
||||
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_digit_strings_are_not_numbers() {
|
||||
assert_eq!(parse_number(&"1".repeat(350), None), None);
|
||||
assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None);
|
||||
assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None);
|
||||
let big = parse_number(&"1".repeat(300), None);
|
||||
assert!(big.is_some_and(f64::is_finite), "{big:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuban_and_peruvian_numbers_use_decimal_points() {
|
||||
for lang in [Some("es-CU"), Some("es-PE")] {
|
||||
|
||||
+30
-3
@@ -193,7 +193,8 @@ impl Table {
|
||||
match idx {
|
||||
Some((i, true)) => {
|
||||
if let Some(c) = row.cells.get_mut(i) {
|
||||
c.props.span += 1;
|
||||
// From the clamped span: a `.json` file can carry any `u32` (#3637).
|
||||
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
|
||||
}
|
||||
}
|
||||
Some((i, false)) => {
|
||||
@@ -223,7 +224,7 @@ impl Table {
|
||||
for (i, c) in row.cells.iter_mut().enumerate() {
|
||||
if g < x + c.span() {
|
||||
if c.span() > 1 {
|
||||
c.props.span -= 1;
|
||||
c.props.span = (c.span() - 1) as u32;
|
||||
} else {
|
||||
rm = Some(i);
|
||||
}
|
||||
@@ -247,7 +248,7 @@ impl Table {
|
||||
let removed: Vec<Cell> = row.cells.drain(c0 + 1..=c1).collect();
|
||||
if let Some(first) = row.cells.get_mut(c0) {
|
||||
for c in removed {
|
||||
first.props.span += c.props.span.max(1);
|
||||
first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32;
|
||||
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
|
||||
first.blocks.extend(nonempty);
|
||||
}
|
||||
@@ -549,6 +550,32 @@ mod tests {
|
||||
t.delete_col(99);
|
||||
}
|
||||
|
||||
/// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it
|
||||
/// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS.
|
||||
#[test]
|
||||
fn a_huge_span_from_a_file_does_not_overflow() {
|
||||
let huge = || {
|
||||
let mut t = Table::new(1, 2, 200.0);
|
||||
t.rows[0].cells[0].props.span = u32::MAX;
|
||||
t
|
||||
};
|
||||
// Column 1 is inside the spanning cell, so the cell widens.
|
||||
let mut t = huge();
|
||||
t.insert_col(1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.merge_right(0, 0, 1);
|
||||
assert_eq!(t.rows[0].cells.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.rows[0].cells[1].props.span = u32::MAX;
|
||||
t.merge_right(0, 0, 1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.delete_col(0);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_and_split() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -90,8 +90,11 @@ pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation.
|
||||
/// The digest is part of the file format, not a choice made here: a stronger hash would
|
||||
/// make password-protected `.doc` files written by Word unreadable.
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha1::new();
|
||||
let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
|
||||
+90
-14
@@ -89,29 +89,105 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32,
|
||||
out
|
||||
}
|
||||
|
||||
/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data).
|
||||
/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not
|
||||
/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped.
|
||||
/// Names are kept to 40 characters, but the reader always advances by the stored length.
|
||||
fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option<Vec<String>> {
|
||||
let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?;
|
||||
if b.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let c_data = u16::from_le_bytes([b[2], b[3]]) as usize;
|
||||
let extended = b.get(0..2)? == [0xFF, 0xFF];
|
||||
let head = if extended { 2 } else { 0 };
|
||||
let h = b.get(head..head + 4)?;
|
||||
let c_data = u16::from_le_bytes([h[0], h[1]]) as usize;
|
||||
let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize;
|
||||
if c_data > MAX {
|
||||
return None;
|
||||
}
|
||||
let mut at = 6usize;
|
||||
let mut at = head + 4;
|
||||
let mut out = Vec::with_capacity(c_data);
|
||||
for _ in 0..c_data {
|
||||
let cch = match b.get(at..at + 2) {
|
||||
Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize,
|
||||
None => break,
|
||||
let (cch, unit) = if extended {
|
||||
match b.get(at..at + 2) {
|
||||
Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2),
|
||||
None => break,
|
||||
}
|
||||
} else {
|
||||
match b.get(at) {
|
||||
Some(&x) => (x as usize, 1),
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
at += unit;
|
||||
let Some(chars) = b.get(at..at + cch * unit) else { break };
|
||||
at += cch * unit;
|
||||
// The string's extra data must be there too, or the table is cut short.
|
||||
if b.get(at..at + cb_extra).is_none() {
|
||||
break;
|
||||
}
|
||||
at += cb_extra;
|
||||
let s: String = if extended {
|
||||
chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect()
|
||||
} else {
|
||||
chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect()
|
||||
};
|
||||
at += 2;
|
||||
let cch = cch.min(40);
|
||||
let chars = b.get(at..at + cch * 2)?;
|
||||
at += cch * 2;
|
||||
let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect();
|
||||
out.push(s);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sttb_strings;
|
||||
|
||||
fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xFF];
|
||||
b.extend_from_slice(&(names.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&(extra.len() as u16).to_le_bytes());
|
||||
for n in names {
|
||||
let units: Vec<u16> = n.encode_utf16().collect();
|
||||
b.extend_from_slice(&(units.len() as u16).to_le_bytes());
|
||||
for u in units {
|
||||
b.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(extra);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_bookmark_name_does_not_desync_table() {
|
||||
let long = "A".repeat(50);
|
||||
let b = ext_sttb(&[&long, "bm2"], &[]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sttb_extra_data_is_skipped() {
|
||||
let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_without_its_extra_data_is_dropped() {
|
||||
let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
b.truncate(b.len() - 2);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_extended_sttb_reads_byte_strings() {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
for n in ["one", "caf\u{e9}"] {
|
||||
let bytes: Vec<u8> = n.chars().map(|c| c as u8).collect();
|
||||
b.push(bytes.len() as u8);
|
||||
b.extend_from_slice(&bytes);
|
||||
b.extend_from_slice(&[9, 9]);
|
||||
}
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]);
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -9,7 +9,7 @@
|
||||
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
|
||||
//! capped as the XML tree reader caps a part's.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use base64::Engine as _;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
@@ -340,14 +340,21 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
|
||||
let rest = frag.get(open..).unwrap_or_default();
|
||||
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
|
||||
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
|
||||
let tag = rest.get(..tag_end).unwrap_or_default();
|
||||
let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default());
|
||||
let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1));
|
||||
let declared: HashSet<Vec<u8>> = start
|
||||
.attributes()
|
||||
.with_checks(false)
|
||||
.flatten()
|
||||
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
|
||||
.map(|a| a.key.as_ref().to_vec())
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(rest.len() + 256);
|
||||
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
|
||||
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for (k, v) in decls.iter().rev() {
|
||||
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
|
||||
if declared || seen.contains(&k.as_str()) {
|
||||
if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
seen.push(k);
|
||||
@@ -463,6 +470,15 @@ mod tests {
|
||||
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_namespaces_do_not_rescan_a_long_root_tag() {
|
||||
let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::<Vec<_>>()];
|
||||
let frag = format!("<w:document{}><w:body/></w:document>", " a=\"1\"".repeat(4_000_000));
|
||||
let out = with_scope(frag.as_bytes(), &scope);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
assert_eq!(out.matches("xmlns:p").count(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
|
||||
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
|
||||
|
||||
@@ -471,6 +471,9 @@ impl Series {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored.
|
||||
const MAX_FORMAT_CODE: usize = 255;
|
||||
|
||||
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
|
||||
/// format code (dates included); text and `General` numbers as they are stored.
|
||||
fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
||||
@@ -478,7 +481,8 @@ fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
||||
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General"));
|
||||
let code =
|
||||
cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General"));
|
||||
pts(cache, cap)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -505,7 +509,7 @@ fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
|
||||
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty());
|
||||
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
|
||||
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
(vals, fmt)
|
||||
}
|
||||
@@ -541,7 +545,12 @@ fn axis(a: Option<&El>) -> Axis {
|
||||
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
|
||||
min: num("c:min"),
|
||||
max: num("c:max"),
|
||||
fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string),
|
||||
fmt: a
|
||||
.child("c:numFmt")
|
||||
.filter(|f| f.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|f| f.attr("formatCode"))
|
||||
.filter(|f| f.len() <= MAX_FORMAT_CODE)
|
||||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2174,6 +2183,44 @@ mod tests {
|
||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_format_code_length_is_capped() {
|
||||
let cat = |code: &str| -> Vec<String> {
|
||||
let x = format!(
|
||||
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
|
||||
);
|
||||
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert!(cat(&ok)[0].starts_with("xxx"));
|
||||
let long = format!("{}0", "x".repeat(300));
|
||||
assert_eq!(cat(&long), ["5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_format_code_length_is_capped() {
|
||||
let val = |code: &str| -> Option<String> {
|
||||
let x = format!(
|
||||
r#"<c:val {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>"#
|
||||
);
|
||||
numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(val(&ok), Some(ok.clone()));
|
||||
assert_eq!(val(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_format_code_length_is_capped() {
|
||||
let fmt = |code: &str| -> Option<String> {
|
||||
let x = format!(r#"<c:valAx {NS}><c:numFmt formatCode="{code}" sourceLinked="0"/></c:valAx>"#);
|
||||
axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(fmt(&ok), Some(ok.clone()));
|
||||
assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_is_exact_beyond_chronos_range() {
|
||||
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||
|
||||
+177
-12
@@ -9,6 +9,7 @@
|
||||
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
|
||||
//! are kept the same way.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
|
||||
@@ -17,7 +18,7 @@ use super::Reader;
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
|
||||
};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc, is_rel_attr};
|
||||
|
||||
/// Relationship types (last path segment) the reader models or the writer produces itself, so
|
||||
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
|
||||
@@ -64,6 +65,51 @@ pub const HANDLED_RELS: &[&str] = &[
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
/// The relationship ids the main document's markup refers to: the ids it names in a
|
||||
/// relationship attribute (`r:id="rId5"`, VML's `o:relid`) — the same rule the embedded objects
|
||||
/// use ([`rel_ids`]).
|
||||
///
|
||||
/// One pass over the markup the reader has already parsed, not one search per id: the ids are
|
||||
/// looked up as they are met, so the cost is the size of the document rather than its size
|
||||
/// times the number of relationships (#12). A name that is not a relationship is ignored, and a
|
||||
/// name that is not one of the document's relationship ids is not collected. An id that only
|
||||
/// appears as text is not a reference: the text does not name a part, and treating it as one
|
||||
/// lost the part on save.
|
||||
fn referenced_ids<'a>(root: &El, document: &'a Rels) -> HashSet<&'a [u8]> {
|
||||
// The ids the loop in `keep_unmodelled` can ask about: the first `MAX_EMBEDDED_RELS`
|
||||
// non-external relationships, and no more, so the set stays bounded however many the file
|
||||
// carries.
|
||||
let ids: HashSet<&[u8]> = document.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS).map(|r| r.id.as_bytes()).collect();
|
||||
let mut found = HashSet::new();
|
||||
if ids.is_empty() {
|
||||
return found;
|
||||
}
|
||||
collect_rel_ids(root, &ids, &mut found, 0);
|
||||
found
|
||||
}
|
||||
|
||||
/// The ids `e` names in a relationship attribute that are in `ids`, collected into `found`.
|
||||
///
|
||||
/// The attribute is a relationship attribute by [`is_rel_attr`]: one in the relationship
|
||||
/// namespace, VML's `o:relid`, or the unqualified alias of one on an element that carries a
|
||||
/// relationship — the same attribute the reader resolves a relationship from, so a part the
|
||||
/// markup names is not carried over a second time.
|
||||
fn collect_rel_ids<'a>(e: &El, ids: &HashSet<&'a [u8]>, found: &mut HashSet<&'a [u8]>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if is_rel_attr(&e.name, k)
|
||||
&& let Some(id) = ids.get(v.as_bytes())
|
||||
{
|
||||
found.insert(*id);
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
collect_rel_ids(c, ids, found, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep custom XML stores reached from the main document or package, without evaluating
|
||||
/// bindings. A bibliography already loaded into the source model belongs to its writer.
|
||||
@@ -99,13 +145,12 @@ impl Reader<'_> {
|
||||
/// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any
|
||||
/// relationship the main document's markup refers to by id (its target goes with the
|
||||
/// element, or not at all).
|
||||
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
|
||||
let pkg = self.pkg;
|
||||
let main = pkg.get(&self.main).unwrap_or_default();
|
||||
let referenced = |id: &str| {
|
||||
let needle = format!("\"{id}\"");
|
||||
main.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
};
|
||||
pub(super) fn keep_unmodelled(&mut self, root: &El, package: &Rels, document: &Rels) {
|
||||
// The ids the main document's markup refers to, in one pass over the markup: an id is
|
||||
// named in a relationship attribute (`r:id="rId5"`). One scan for all of them, not one
|
||||
// search per relationship — a large document with many relationships took minutes to
|
||||
// open (#12).
|
||||
let referenced = referenced_ids(root, document);
|
||||
let mut roots = String::new();
|
||||
// One glossary per main document (its first relationship; see `read_glossary`).
|
||||
let mut glossary = false;
|
||||
@@ -113,7 +158,7 @@ impl Reader<'_> {
|
||||
for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) {
|
||||
let tail = rel.kind.rsplit('/').next().unwrap_or("");
|
||||
if HANDLED_RELS.contains(&tail)
|
||||
|| (owner == "D" && referenced(&rel.id))
|
||||
|| (owner == "D" && referenced.contains(rel.id.as_bytes()))
|
||||
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|
||||
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
|
||||
{
|
||||
@@ -215,14 +260,15 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's
|
||||
/// `o:relid`), in document order. `None` past [`MAX_IDS`].
|
||||
/// The relationship ids `e` uses (attributes in the relationships namespace, VML's `o:relid`,
|
||||
/// and the unqualified alias of one on an element that carries a relationship), in document
|
||||
/// order. `None` past [`MAX_IDS`].
|
||||
fn rel_ids(e: &El, out: &mut Vec<String>, depth: usize) -> Option<()> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Some(());
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if (k.starts_with("r:") || k == "o:relid") && !out.contains(v) {
|
||||
if is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
if out.len() >= MAX_IDS {
|
||||
return None;
|
||||
}
|
||||
@@ -247,3 +293,122 @@ fn standalone_xml(e: &El) -> Option<String> {
|
||||
s.push_str(rest);
|
||||
(s.len() <= MAX_EMBEDDED_XML).then_some(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use crate::package::UNMODELLED_ROOTS;
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
|
||||
/// A package whose main part holds `body` (the XML inside `w:body`), and which relates to
|
||||
/// `rels` parts of a type the reader doesn't model, so each one is looked for in the part.
|
||||
fn package(body: &str, rels: &[(&str, &str)], parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut r = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for (id, target) in rels {
|
||||
r.push_str(&format!(
|
||||
r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings" Target="{target}"/>"#
|
||||
));
|
||||
}
|
||||
r.push_str("</Relationships>");
|
||||
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
zw.start_file("[Content_Types].xml", opts).unwrap();
|
||||
zw.write_all(CONTENT_TYPES.as_bytes()).unwrap();
|
||||
zw.start_file("_rels/.rels", opts).unwrap();
|
||||
zw.write_all(ROOT_RELS.as_bytes()).unwrap();
|
||||
zw.start_file("word/document.xml", opts).unwrap();
|
||||
zw.write_all(doc.as_bytes()).unwrap();
|
||||
zw.start_file("word/_rels/document.xml.rels", opts).unwrap();
|
||||
zw.write_all(r.as_bytes()).unwrap();
|
||||
for (name, bytes) in parts {
|
||||
zw.start_file(name, opts).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The parts the reader carries over unchanged, as one string.
|
||||
fn unmodelled_roots(d: &wordcraft_doc::Document) -> String {
|
||||
d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Opening a document costs the size of its main part, not that size times the number of its
|
||||
/// relationships (#12). Eight megabytes of main part with a thousand relationships used to
|
||||
/// take half a minute; the scan is now one pass over the markup.
|
||||
#[test]
|
||||
fn a_document_with_many_relationships_opens_in_the_time_its_part_takes() {
|
||||
let rels: Vec<(String, String)> = (0..1024).map(|i| (format!("rIdT{i}"), format!("file{i}.bin"))).collect();
|
||||
let rels: Vec<(&str, &str)> = rels.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
|
||||
let body = format!(r#"<w:p><w:r><w:t xml:space="preserve">{}</w:t></w:r></w:p>"#, "a".repeat(8 << 20));
|
||||
let bytes = package(&body, &rels, &[]);
|
||||
let t = std::time::Instant::now();
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let took = t.elapsed();
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).len(), 8 << 20, "the text is there");
|
||||
assert!(took.as_secs() < 15, "opening it took {took:?}");
|
||||
}
|
||||
|
||||
/// The scan finds the relationships the markup names, so a part the document already refers
|
||||
/// to is not carried over a second time (#12).
|
||||
#[test]
|
||||
fn a_relationship_the_markup_names_is_not_carried_over_again() {
|
||||
// The markup names the relationship, so it is not carried over a second time as an
|
||||
// unmodelled part. (The part itself survives only if the writer writes it with the
|
||||
// element that names it; the reader does not carry it on its own.)
|
||||
let named = package(
|
||||
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p><w:sectPr><w:printerSettings r:id="rIdDoc1"/></w:sectPr>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&named).unwrap());
|
||||
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
|
||||
|
||||
// A part the markup doesn't name is carried over, as before.
|
||||
let unnamed = package(r#"<w:p><w:r><w:t>no mention here</w:t></w:r></w:p>"#, &[("rIdDoc1", "keep.bin")], &[("word/keep.bin", b"kept")]);
|
||||
let roots = unmodelled_roots(&crate::read(&unnamed).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// An id the markup only mentions as text does not name a part: the part is carried over,
|
||||
/// not dropped because the text looked like a reference.
|
||||
#[test]
|
||||
fn a_part_the_markup_only_mentions_is_carried_over() {
|
||||
let mentioned = package(
|
||||
r#"<w:p><w:r><w:t xml:space="preserve">before "rIdDoc1" after</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&mentioned).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
fn an_unqualified_attribute_names_a_relationship_only_where_it_is_one() {
|
||||
// A hyperlink whose relationship id is written without the prefix: the reader resolves
|
||||
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
|
||||
let named = package(
|
||||
r#"<w:p><w:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&named).unwrap());
|
||||
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
|
||||
|
||||
// A VML shape's `id` is its own: it names no part, so the part is carried over.
|
||||
let own = package(
|
||||
r#"<w:p><w:r><w:pict><v:shape id="rIdDoc1" style="width:10pt;height:10pt"/></w:pict></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&own).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,10 @@ fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
|
||||
};
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
// Cap the commands too, so a path of bare letters can't build a million entries.
|
||||
if out.len() >= MAX_VML_PARAMS {
|
||||
break;
|
||||
}
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
@@ -361,6 +365,13 @@ mod tests {
|
||||
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() {
|
||||
let path = "x".repeat(MAX_VML_PATH);
|
||||
let n = vml_commands(&path).len();
|
||||
assert!(n <= MAX_VML_PARAMS, "{n} commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bezier_segment_with_many_points_is_bounded() {
|
||||
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
|
||||
|
||||
@@ -299,7 +299,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
}
|
||||
r.read_glossary(&rels);
|
||||
r.keep_custom_xml(&root_rels, &rels);
|
||||
r.keep_unmodelled(&root_rels, &rels);
|
||||
r.keep_unmodelled(&root, &root_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
|
||||
//! parser's depth limit, pictures and stories are capped in number.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::Document;
|
||||
@@ -38,6 +38,8 @@ const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
|
||||
const MAX_MEDIA: usize = 4096;
|
||||
/// Most header, footer, footnote, endnote and comment stories written.
|
||||
const MAX_STORIES: usize = 50_000;
|
||||
/// Most bytes of header and footer parts inlined, all sections together.
|
||||
const MAX_INLINED_BYTES: usize = 64 << 20;
|
||||
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
|
||||
const MAX_PICT_PT: f64 = 31_680.0;
|
||||
|
||||
@@ -63,6 +65,9 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
if c.over_budget {
|
||||
return Err(DocxError::Limit(format!("headers and footers inlined past {MAX_INLINED_BYTES} bytes")));
|
||||
}
|
||||
|
||||
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
|
||||
if let Some(f) = c.default_fonts() {
|
||||
@@ -153,6 +158,14 @@ struct Conv<'a> {
|
||||
next_shape: i64,
|
||||
stories: usize,
|
||||
in_note: bool,
|
||||
/// Notes and comments already inlined: (kind, id). A repeated reference does not copy the body again.
|
||||
inlined_notes: HashSet<(char, String)>,
|
||||
/// The header or footer in effect per (footer, type), as the previous sections left it.
|
||||
hf_in_effect: HashMap<(bool, &'static str), String>,
|
||||
/// Bytes of header and footer parts inlined so far.
|
||||
inlined_bytes: usize,
|
||||
/// A header or footer did not fit in `MAX_INLINED_BYTES`; the export fails.
|
||||
over_budget: bool,
|
||||
}
|
||||
|
||||
impl<'a> Conv<'a> {
|
||||
@@ -197,6 +210,10 @@ impl<'a> Conv<'a> {
|
||||
next_shape: 1025,
|
||||
stories: 0,
|
||||
in_note: false,
|
||||
inlined_notes: HashSet::new(),
|
||||
hf_in_effect: HashMap::new(),
|
||||
inlined_bytes: 0,
|
||||
over_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,10 +385,14 @@ impl<'a> Conv<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
// The body goes at the first reference only; a repeated one keeps its mark, empty.
|
||||
if !self.inlined_notes.insert((if foot { 'f' } else { 'e' }, id.to_string())) {
|
||||
return vec![Node::El(plain(name, Vec::new()))];
|
||||
}
|
||||
self.stories += 1;
|
||||
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
|
||||
self.in_note = true;
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
let kids = self.kids(¬e, &rels, name, depth);
|
||||
self.in_note = false;
|
||||
vec![Node::El(plain(name, kids))]
|
||||
@@ -390,6 +411,10 @@ impl<'a> Conv<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
|
||||
// A comment is written once, at its first reference.
|
||||
if !self.inlined_notes.insert(('c', id.to_string())) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.stories += 1;
|
||||
let rels = self.comment_rels.clone();
|
||||
let n = self.ann_id('c', id);
|
||||
@@ -448,14 +473,27 @@ impl<'a> Conv<'a> {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let ty = match k.attr("w:type") {
|
||||
Some("even") => "even",
|
||||
Some("first") => "first",
|
||||
_ => "odd",
|
||||
};
|
||||
// A section without the story inherits the previous section's, as in OOXML: write
|
||||
// it only where it changes.
|
||||
if self.hf_in_effect.get(&(footer, ty)) == Some(&path) {
|
||||
continue;
|
||||
}
|
||||
let size = self.pkg.get(&path).map_or(0, |b| b.len());
|
||||
if self.inlined_bytes.saturating_add(size) > MAX_INLINED_BYTES {
|
||||
// Skipping would leave later sections inheriting the wrong story: fail instead.
|
||||
self.over_budget = true;
|
||||
continue;
|
||||
}
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
self.inlined_bytes = self.inlined_bytes.saturating_add(size);
|
||||
self.hf_in_effect.insert((footer, ty), path.clone());
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let name = if footer { "w:ftr" } else { "w:hdr" };
|
||||
let was = std::mem::replace(&mut self.in_note, true);
|
||||
let kids = self.kids(&root, &story_rels, name, depth + 1);
|
||||
|
||||
@@ -121,6 +121,8 @@ impl<'d> EmbedWriter<'d> {
|
||||
|
||||
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
|
||||
fn alloc(&mut self, orig: &str) -> String {
|
||||
let clean = valid_part_name(orig);
|
||||
let orig = clean.as_str();
|
||||
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
|
||||
// part needs a different base name, otherwise every candidate would be reserved.
|
||||
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
|
||||
@@ -585,17 +587,12 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)?
|
||||
fn is_rel_attr(k: &str) -> bool {
|
||||
k.starts_with("r:") || k == "o:relid"
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if is_rel_attr(k) && !out.contains(v) {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
}
|
||||
}
|
||||
@@ -608,8 +605,9 @@ fn set_rel_ids(e: &mut El, ids: &HashMap<String, String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let name = e.name.clone();
|
||||
for (k, v) in e.attrs.iter_mut() {
|
||||
if is_rel_attr(k)
|
||||
if xml::is_rel_attr(&name, k)
|
||||
&& let Some(n) = ids.get(v.as_str())
|
||||
{
|
||||
*v = n.clone();
|
||||
@@ -684,6 +682,29 @@ fn pt(v: f32) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a
|
||||
/// dot, or encoded separators.
|
||||
fn valid_part_name(name: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
for seg in name.split('/') {
|
||||
let mut seg = seg.replace('\\', "_");
|
||||
for enc in ["%2f", "%5c"] {
|
||||
while let Some(i) = seg.to_ascii_lowercase().find(enc) {
|
||||
seg.replace_range(i..i + 3, "_");
|
||||
}
|
||||
}
|
||||
if seg.is_empty() || seg == "." || seg == ".." {
|
||||
continue;
|
||||
}
|
||||
// A segment can't end in a dot either (`word/a.`).
|
||||
if seg.ends_with('.') {
|
||||
seg = format!("{}_", seg.trim_end_matches('.'));
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -714,4 +735,16 @@ mod tests {
|
||||
// Unturned: no rotation or flip left behind.
|
||||
assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_part_names_are_valid_opc_names() {
|
||||
let doc = wordcraft_doc::Document::default();
|
||||
let mut w = EmbedWriter::new(&doc);
|
||||
for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] {
|
||||
let name = w.alloc(orig);
|
||||
assert!(!name.contains('\\'), "{name}");
|
||||
assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}");
|
||||
assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@ pub(crate) struct Writer<'d> {
|
||||
used_media: std::collections::BTreeSet<String>,
|
||||
/// Bounds writing text boxes inside text boxes (as layout shows them).
|
||||
boxes: wordcraft_doc::BoxBudget,
|
||||
/// Text box stories already written: a story shown by many shapes is written once, the other
|
||||
/// shapes get an empty box, so the file doesn't grow as shapes × story size.
|
||||
written_boxes: std::collections::HashSet<u32>,
|
||||
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
|
||||
embeds: embed::EmbedWriter<'d>,
|
||||
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
|
||||
@@ -175,6 +178,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
inline_controls: Vec::new(),
|
||||
used_media: Default::default(),
|
||||
boxes: wordcraft_doc::BoxBudget::default(),
|
||||
written_boxes: std::collections::HashSet::new(),
|
||||
embeds: embed::EmbedWriter::new(doc),
|
||||
txbx_links: doc
|
||||
.text_box_chains()
|
||||
|
||||
@@ -153,11 +153,26 @@ struct Child {
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// Byte index of the `>` that ends the tag at the start of `s`, skipping quoted attribute values.
|
||||
fn tag_end(s: &str) -> Option<usize> {
|
||||
let mut quote = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(None, '>') => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The root start tag's span end and the root's direct children, or `None` for anything the
|
||||
/// scanner doesn't expect (CDATA, DOCTYPE, a different root).
|
||||
fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
|
||||
let open = xml.find(&format!("<{root}"))?;
|
||||
let open_end = open + xml.get(open..)?.find('>')? + 1;
|
||||
let open_end = open + tag_end(xml.get(open..)?)? + 1;
|
||||
if xml.get(open_end - 2..open_end)? == "/>" {
|
||||
return None;
|
||||
}
|
||||
@@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
|
||||
i = at + tail.find("?>")? + 2;
|
||||
continue;
|
||||
}
|
||||
let gt = at + tail.find('>')? + 1;
|
||||
let gt = at + tag_end(tail)? + 1;
|
||||
let tag = xml.get(at..gt)?;
|
||||
if tag.starts_with("</") {
|
||||
depth = depth.checked_sub(1)?;
|
||||
@@ -343,6 +358,18 @@ mod tests {
|
||||
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() {
|
||||
for v in [r#"w:val="x>y""#, "w:val='x>y'"] {
|
||||
let orig = format!(
|
||||
r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:docVar w:name="a" {v}/><w:docVar w:name="b" {v}></w:docVar></w:settings>"#
|
||||
);
|
||||
assert!(has_unowned(orig.as_bytes()), "{v}");
|
||||
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
|
||||
assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compat_block_without_a_mode_gets_the_generated_one() {
|
||||
let orig = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:compat><w:useFELayout/></w:compat></w:settings>"#;
|
||||
|
||||
@@ -1126,8 +1126,12 @@ impl Writer<'_> {
|
||||
None => w.open("wps:txbx", &[]),
|
||||
}
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
if self.written_boxes.insert(id) {
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
} else {
|
||||
w.empty("w:p", &[]);
|
||||
}
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
|
||||
@@ -116,6 +116,52 @@ pub enum Node {
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Elements whose markup names a relationship: the id is in an attribute of the relationship
|
||||
/// namespace (`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`…) or VML's `o:relid`, and — from a
|
||||
/// producer that left the prefix out — in the unqualified attribute of the same local name, the
|
||||
/// alias [`El::attr`] accepts. On any other element an unqualified `id` is the element's own
|
||||
/// (a drawing's `wp:docPr`, a picture's `pic:cNvPr`, a VML shape, a page background) and names
|
||||
/// no part.
|
||||
const REL_ELEMENTS: &[&str] = &[
|
||||
"a:blip",
|
||||
"a:hlinkClick",
|
||||
"a:hlinkHover",
|
||||
"a:audioFile",
|
||||
"a:videoFile",
|
||||
"c:chart",
|
||||
"cx:chart",
|
||||
"dgm:relIds",
|
||||
"o:OLEObject",
|
||||
"v:fill",
|
||||
"v:imagedata",
|
||||
"w:attachedTemplate",
|
||||
"w:control",
|
||||
"w:embedBold",
|
||||
"w:embedBoldItalic",
|
||||
"w:embedItalic",
|
||||
"w:embedRegular",
|
||||
"w:footerReference",
|
||||
"w:headerReference",
|
||||
"w:hyperlink",
|
||||
"w:object",
|
||||
"w:subDoc",
|
||||
"w14:contentPart",
|
||||
];
|
||||
|
||||
/// The local names a relationship attribute has, so an unqualified one is recognised as the
|
||||
/// relationship id it names.
|
||||
const REL_ALIASES: &[&str] = &["id", "embed", "link", "pict", "dm", "lo", "qs", "cs", "relid"];
|
||||
|
||||
/// Is the attribute `k` of the element named `element` a relationship id?
|
||||
///
|
||||
/// The relationship namespace (`r:`) and VML's `o:relid` always are. An unqualified attribute
|
||||
/// is one only on an element that carries a relationship ([`REL_ELEMENTS`]) and only with the
|
||||
/// local name of a relationship attribute ([`REL_ALIASES`]) — the alias [`El::attr`] accepts.
|
||||
/// Everywhere else an unqualified `id` is the element's own and names no part.
|
||||
pub fn is_rel_attr(element: &str, k: &str) -> bool {
|
||||
k.starts_with("r:") || k == "o:relid" || (REL_ELEMENTS.contains(&element) && REL_ALIASES.contains(&k))
|
||||
}
|
||||
|
||||
/// An element with canonical-prefixed name and attributes.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct El {
|
||||
@@ -521,6 +567,26 @@ pub fn body_ns() -> Vec<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
assert!(is_rel_attr("w:hyperlink", "r:id"));
|
||||
assert!(is_rel_attr("v:imagedata", "o:relid"));
|
||||
// The unqualified alias `El::attr` accepts, on an element that carries a relationship.
|
||||
assert!(is_rel_attr("w:hyperlink", "id"));
|
||||
assert!(is_rel_attr("v:imagedata", "id"));
|
||||
assert!(is_rel_attr("a:blip", "embed"));
|
||||
assert!(is_rel_attr("dgm:relIds", "dm"));
|
||||
// An ordinary `id` — the element's own, naming no part — is not a relationship.
|
||||
assert!(!is_rel_attr("v:shape", "id"));
|
||||
assert!(!is_rel_attr("pic:cNvPr", "id"));
|
||||
assert!(!is_rel_attr("wp:docPr", "id"));
|
||||
assert!(!is_rel_attr("v:background", "id"));
|
||||
// An attribute that is not a relationship attribute at all.
|
||||
assert!(!is_rel_attr("w:hyperlink", "anchor"));
|
||||
assert!(!is_rel_attr("v:imagedata", "title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_parts_parse_and_odd_ones_never_panic() {
|
||||
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1643,6 +1643,39 @@ fn ole_object_round_trips() {
|
||||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unqualified_relationship_attribute_round_trips() {
|
||||
// A producer that left the relationship prefix out: the VML picture names its image with an
|
||||
// unqualified `id`. The reader resolves it (the alias `El::attr` accepts), the writer keeps
|
||||
// the attribute and gives it the id the new package holds the image under.
|
||||
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
|
||||
let picture = "\u{89}PNG stand-in";
|
||||
let ole = "\u{d0}\u{cf} opaque OLE storage";
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
|
||||
let bytes = docx(
|
||||
body,
|
||||
&[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")],
|
||||
&[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)],
|
||||
);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let Some(InlineObject::Image { ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||||
// The attribute is still unqualified, and names the image the new package holds.
|
||||
let [pic_id] = attr_values(&body, "v:imagedata", "id").try_into().unwrap();
|
||||
let (kind, target) = rel(&rels, &pic_id);
|
||||
assert!(kind.ends_with("/image"), "{kind}");
|
||||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||||
// The object itself is kept whole, as before.
|
||||
let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&rels, &ole_id);
|
||||
assert!(kind.ends_with("/oleObject"), "{kind}");
|
||||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(ole.as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_line_round_trips() {
|
||||
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,32 @@
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
mod complex_script_inheritance;
|
||||
mod content_control_save_cost;
|
||||
mod content_controls;
|
||||
mod custom_xml;
|
||||
mod drawing_payload;
|
||||
mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod passthrough;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod section_form_protection;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
mod wordml2003_write;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -234,3 +234,16 @@ fn picture_in_box_keeps_its_frame() {
|
||||
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_shared_by_many_shapes_is_written_once() {
|
||||
let mut d = Document::from_text("x");
|
||||
let text = "abcdefghij".repeat(5_000);
|
||||
let o = text_box(&mut d, &text, TextBody::default());
|
||||
for _ in 0..500 {
|
||||
d.insert_object(&wordcraft_doc::Pos::body(0, 0), o.clone(), &Default::default()).unwrap();
|
||||
}
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.len() < 2_000_000, "document.xml is {} bytes", xml.len());
|
||||
assert_eq!(xml.matches(text.as_str()).count(), 1);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -179,3 +179,51 @@ fn big_and_deep_documents_write() {
|
||||
let _ = read_xml(&xml);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
|
||||
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
|
||||
// times must not be copied into the output once per section or reference.
|
||||
let mut d = Document::new();
|
||||
let big = format!("HDRMARK {}", "h".repeat(10_000));
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&big, CharProps::default()))]);
|
||||
let note = format!("NOTEMARK {}", "n".repeat(10_000));
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text(¬e, CharProps::default()))]);
|
||||
d.last_section.headers.default = Some(head);
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..1000 {
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
if i < 500 {
|
||||
p.insert_object(1, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
|
||||
}
|
||||
p.section = Some(Box::new(d.last_section.clone()));
|
||||
blocks.push(para_block(p));
|
||||
}
|
||||
d.body = blocks;
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
let text = String::from_utf8(xml).expect("utf-8");
|
||||
let hdr = text.matches("HDRMARK").count();
|
||||
let notes = text.matches("NOTEMARK").count();
|
||||
assert!(text.len() < 2_000_000 && hdr <= 3 && notes <= 3, "output {} bytes, header x{hdr}, note x{notes}", text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_past_the_inlined_byte_budget_fail_the_export() {
|
||||
// #3652: two ~1 MB headers alternating over 80 sections must each be written where they
|
||||
// change; past the byte budget the export fails rather than growing or dropping a header.
|
||||
let mut d = Document::new();
|
||||
let a = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"a".repeat(1_000_000), CharProps::default()))]);
|
||||
let b = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"b".repeat(1_000_000), CharProps::default()))]);
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..80 {
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
let mut s = d.last_section.clone();
|
||||
s.headers.default = Some(if i % 2 == 0 { a } else { b });
|
||||
p.section = Some(Box::new(s));
|
||||
blocks.push(para_block(p));
|
||||
}
|
||||
d.body = blocks;
|
||||
let out = write_wordml2003(&d);
|
||||
assert!(matches!(out, Err(wordcraft_docx::DocxError::Limit(_))), "expected a limit error, got {:?}", out.map(|x| x.len()));
|
||||
}
|
||||
@@ -279,9 +279,13 @@ Mailings|Write & Insert Fields|Match Fields|mailings.matchFields
|
||||
Mailings|Preview Results|Preview Results|mailings.preview
|
||||
Mailings|Preview Results|Next Record|mailings.next
|
||||
Mailings|Preview Results|Previous Record|mailings.previous
|
||||
Mailings|Preview Results|First Record|mailings.first
|
||||
Mailings|Preview Results|Last Record|mailings.last
|
||||
Mailings|Preview Results|Go to Record|mailings.goToRecord
|
||||
Mailings|Preview Results|Find Recipient|mailings.findRecipient
|
||||
Mailings|Preview Results|Check for Errors|mailings.checkErrors
|
||||
Mailings|Finish|Finish & Merge|mailings.finish
|
||||
Mailings|Finish|Print Documents|mailings.printDocuments
|
||||
Review|Proofing|Spelling & Grammar|review.spelling
|
||||
Review|Proofing|Thesaurus|review.thesaurus
|
||||
Review|Proofing|Word Count|review.wordCount
|
||||
|
||||
@@ -145,7 +145,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let kind = chart_type(v)?;
|
||||
// No type and no data: Insert › Chart's picker, as the ribbon button does.
|
||||
if kind.is_none() && v.get("data").is_none() {
|
||||
s.ui_requests.push(json!({"open": "insertChart"}));
|
||||
s.push_ui_request(json!({"open": "insertChart"}));
|
||||
return sel_result(s);
|
||||
}
|
||||
let kind = kind.unwrap_or_default();
|
||||
|
||||
@@ -529,6 +529,7 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
|
||||
}
|
||||
|
||||
/// The author of a source for matching untagged entries: the editor when there is no author.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn by_author(x: &Source) -> &str {
|
||||
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
let mut moved = false;
|
||||
for pos in groups {
|
||||
if let Ok(para) = s.doc.para_mut(pos.story, &pos.path)
|
||||
&& let Some(InlineObject::Group { children, .. }) = para.object_at_mut(pos.off)
|
||||
&& wordcraft_doc::connector::reroute_group(children)
|
||||
&& let Some(group @ InlineObject::Group { .. }) = para.object_at_mut(pos.off)
|
||||
&& wordcraft_doc::connector::reroute_group(group)
|
||||
{
|
||||
para.touch();
|
||||
moved = true;
|
||||
@@ -290,3 +290,103 @@ fn connect(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::connector::ShapeExtra;
|
||||
use wordcraft_doc::para::{Float, GroupChild, ShapeKind};
|
||||
|
||||
fn shape(kind: ShapeKind, extra: ShapeExtra, float: Float) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind,
|
||||
w: 40.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
|
||||
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
|
||||
let rects = group.group_rects(0.0, 0.0, *w, *h);
|
||||
let ([x, y, w, h], obj) = rects[1];
|
||||
let own = obj.frame().unwrap().2.spin();
|
||||
let outer = float.spin();
|
||||
let turn = |(x, y)| {
|
||||
let (x, y) = own.apply(rects[1].0[0] + w / 2.0, rects[1].0[1] + h / 2.0, x, y);
|
||||
let InlineObject::Group { w, h, .. } = group else { panic!("group") };
|
||||
outer.apply(*w / 2.0, *h / 2.0, x, y)
|
||||
};
|
||||
(turn((x, y)), turn((x + w, y + h)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
|
||||
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
|
||||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||||
for rot in [30.0, 90.0] {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let group = InlineObject::Group {
|
||||
w,
|
||||
h,
|
||||
ch_w: 200.0,
|
||||
ch_h: 300.0,
|
||||
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
|
||||
canvas: None,
|
||||
children: vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()),
|
||||
},
|
||||
GroupChild {
|
||||
x: 10.0,
|
||||
y: 30.0,
|
||||
obj: shape(
|
||||
kind,
|
||||
ShapeExtra { start, end, ..Default::default() },
|
||||
Float { rot, flip_h, flip_v, ..Default::default() },
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
// Rendering scales each child rectangle before applying its own spin.
|
||||
let before = visual_ends(&group);
|
||||
let expected = if free_start { before.0 } else { before.1 };
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let pos = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
|
||||
assert!(reroute(&mut s));
|
||||
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
|
||||
let after = visual_ends(group);
|
||||
let actual = if free_start { after.0 } else { after.1 };
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
|
||||
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
|
||||
);
|
||||
let InlineObject::Group { children, .. } = group else { panic!("group") };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
|
||||
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute(&mut s), "rerouting must settle");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,11 @@ fn symbol_param(v: &Value, k: &str) -> Option<char> {
|
||||
/// A date `YYYY-MM-DD` (a full ISO date-time is fine too): (year, month, day), checked.
|
||||
pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
|
||||
let t = t.trim();
|
||||
let y: i64 = t.get(0..4)?.parse().ok()?;
|
||||
let m: u32 = t.get(5..7)?.parse().ok()?;
|
||||
let d: u32 = t.get(8..10)?.parse().ok()?;
|
||||
// Digits only: `str::parse` would also accept a leading '+'.
|
||||
let field = |r: std::ops::Range<usize>| t.get(r).filter(|s| s.bytes().all(|b| b.is_ascii_digit()));
|
||||
let y: i64 = field(0..4)?.parse().ok()?;
|
||||
let m: u32 = field(5..7)?.parse().ok()?;
|
||||
let d: u32 = field(8..10)?.parse().ok()?;
|
||||
if t.get(4..5) != Some("-") || t.get(7..8) != Some("-") || !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m)
|
||||
{
|
||||
return None;
|
||||
@@ -761,6 +763,15 @@ mod tests {
|
||||
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_rejects_signs_and_non_digits() {
|
||||
for bad in ["2024-+1-+5", "+123-01-01", "2024-01-+5", "2024-+1-05", "-024-01-01"] {
|
||||
assert_eq!(parse_date(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(parse_date("2024-01-05"), Some((2024, 1, 5)));
|
||||
assert_eq!(parse_date("2024-01-05T10:00:00"), Some((2024, 1, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
t @ ("find" | "replace" | "goto") => t,
|
||||
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
|
||||
};
|
||||
s.ui_requests.push(json!({"open": tab}));
|
||||
s.push_ui_request(json!({"open": tab}));
|
||||
Ok(json!({"open": tab}))
|
||||
})
|
||||
// Word for Mac's own key is ⌃H.
|
||||
@@ -603,7 +603,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
|
||||
// Find and Replace dialog is Advanced Find.
|
||||
s.view.nav_pane = true;
|
||||
s.ui_requests.push(json!({"focus": "navSearch"}));
|
||||
s.push_ui_request(json!({"focus": "navSearch"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
@@ -624,7 +624,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
|
||||
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
s.push_ui_request(json!({"open": "find"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
read_opts(s, v);
|
||||
@@ -748,7 +748,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.sel = Selection::caret(t);
|
||||
s.page_hint = idx;
|
||||
}
|
||||
s.ui_requests.push(json!({"scrollTo": "caret"}));
|
||||
s.push_ui_request(json!({"scrollTo": "caret"}));
|
||||
return sel_result(s);
|
||||
}
|
||||
if let Some(name) = p::str(v, "bookmark") {
|
||||
@@ -769,7 +769,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
s.ui_requests.push(json!({"open": "goto"}));
|
||||
s.push_ui_request(json!({"open": "goto"}));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc";
|
||||
|
||||
pub const MAX_AUTHOR_CHARS: usize = 255;
|
||||
|
||||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||||
/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to
|
||||
/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author.
|
||||
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
|
||||
/// The user name as it is stored: trimmed, stripped of control characters (they can't go into
|
||||
/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always
|
||||
/// carry an author.
|
||||
pub fn normalize_author(name: &str) -> Result<String, CmdError> {
|
||||
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
|
||||
let name = name.trim_end();
|
||||
if name.is_empty() {
|
||||
return Err(CmdError::Params("`name` must not be blank".into()));
|
||||
}
|
||||
s.author = name.to_string();
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||||
/// normalised by [`normalize_author`].
|
||||
/// A protected document refuses it: the name is the identity its exceptions are checked against.
|
||||
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
|
||||
super::protect::check_user_name(s)?;
|
||||
s.author = normalize_author(p::req_str(v, "name")?)?;
|
||||
let mut r = sel_result(s)?;
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("name".into(), json!(s.author));
|
||||
@@ -93,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// No path (the command search, say): the app asks where to save, as File › Export
|
||||
// does, instead of failing on the required parameter (#2313).
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.ui_requests.push(json!({"open": "exportPdf"}));
|
||||
s.push_ui_request(json!({"open": "exportPdf"}));
|
||||
return Ok(json!({"exported": false}));
|
||||
};
|
||||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||||
@@ -119,13 +127,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "print"}));
|
||||
s.push_ui_request(json!({"open": "print"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
|
||||
.pure(),
|
||||
CommandSpec::new("file.close", "Close", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"close": true}));
|
||||
s.push_ui_request(json!({"close": true}));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+W / Mod+F4")
|
||||
@@ -219,7 +227,7 @@ fn normal_document(s: &Session) -> Document {
|
||||
/// Open a document, using the saved proofing default only for plain text and Markdown.
|
||||
fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.ui_requests.push(json!({"open": "openFile"}));
|
||||
s.push_ui_request(json!({"open": "openFile"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
let password = p::str(v, "password");
|
||||
@@ -282,7 +290,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
s.push_ui_request(json!({"open": "saveAs"}));
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
};
|
||||
@@ -349,23 +357,29 @@ fn is_word_2003_file(_path: &std::path::Path) -> bool {
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "path").is_none() {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
s.push_ui_request(json!({"open": "saveAs"}));
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
save(s, v)
|
||||
}
|
||||
|
||||
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
|
||||
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
|
||||
/// setting (`file.printMarkup`); comments as Track Changes Options show them; digits as the
|
||||
/// Numeral setting shows them on screen.
|
||||
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
||||
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
|
||||
crate::io::ExportOptions {
|
||||
markup,
|
||||
comments: markup && s.prefs.markup.comments,
|
||||
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
|
||||
numeral: s.view.numeral.resolve(crate::session::system_locale()),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// No path: the app asks where to save, as file.exportPdf does (#2313).
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.ui_requests.push(json!({"open": "exportPng"}));
|
||||
s.push_ui_request(json!({"open": "exportPng"}));
|
||||
return Ok(json!({"exported": false}));
|
||||
};
|
||||
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
|
||||
@@ -376,6 +390,9 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// the on-screen cue while editing the body, not part of the page.
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.dim_header = false;
|
||||
if !path.to_ascii_lowercase().ends_with(".png") {
|
||||
return Err(CmdError::Params("path must end in .png".into()));
|
||||
}
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
|
||||
let png = img.to_png();
|
||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn load_system(s: &mut Session, _v: &Value) -> CmdResult {
|
||||
s.ui_requests.push(json!({"open": "loadSystemFonts"}));
|
||||
s.push_ui_request(json!({"open": "loadSystemFonts"}));
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
} else {
|
||||
// From the ribbon: the UI shows a file picker and runs this again with the file.
|
||||
s.ui_requests.push(json!({"open": "addFont"}));
|
||||
s.push_ui_request(json!({"open": "addFont"}));
|
||||
return Ok(json!({"pending": true}));
|
||||
};
|
||||
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
|
||||
use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::resolve::ResolvedChar;
|
||||
use wordcraft_doc::{Pos, RevisionKind};
|
||||
use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
|
||||
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
|
||||
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "font"}));
|
||||
s.push_ui_request(json!({"open": "font"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+D")
|
||||
@@ -170,6 +170,14 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A text box selected as an object (its frame, not text in it): all its text, which Word's Font
|
||||
/// group formats then, instead of the box's anchor in the text around it.
|
||||
fn selected_text_box(s: &Session) -> Option<(Pos, Pos)> {
|
||||
let (_, InlineObject::Shape { story: Some(id), .. }) = super::objects::object_selection(s)? else { return None };
|
||||
let story = StoryRef::Part(*id);
|
||||
s.doc.parts.get(id).filter(|p| p.kind == PartKind::TextBox).map(|_| (s.doc.start_of(story), s.doc.end_of(story)))
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph:
|
||||
/// an empty one's mark takes the formatting.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
@@ -198,7 +206,7 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
match target(s) {
|
||||
match selected_text_box(s).or_else(|| target(s)) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
}
|
||||
@@ -242,7 +250,7 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let Some((a, b)) = target(s) else {
|
||||
let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) else {
|
||||
let f = &s.sel.focus;
|
||||
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
|
||||
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
|
||||
@@ -262,8 +270,11 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone());
|
||||
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
|
||||
let p = s.doc.para_at(&a);
|
||||
let style = p.and_then(|p| p.props.style.clone());
|
||||
// An empty selected text box has only its paragraph mark, which is what gets formatted.
|
||||
let empty_box = selected_text_box(s).and(p).map(|p| &p.mark);
|
||||
out.push(s.doc.styles.resolve_char(style.as_deref(), empty_box.unwrap_or(&s.typing_props())));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -393,9 +404,16 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.highlight = Some(h))
|
||||
}
|
||||
|
||||
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
|
||||
/// without one. Works on the selection, the word at the caret, or a selected text box's text.
|
||||
fn change_case(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) };
|
||||
let (a, b) = match selected_text_box(s) {
|
||||
Some(all) => all,
|
||||
None => {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b { target(s).unwrap_or((a, b)) } else { (a, b) }
|
||||
}
|
||||
};
|
||||
if a == b {
|
||||
return sel_result(s);
|
||||
}
|
||||
@@ -630,8 +648,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `format.clear` (Clear All Formatting): direct character formatting off and the paragraphs back
|
||||
/// to Normal, for the selection or a selected text box's text; at a caret, for what's typed next.
|
||||
fn clear(s: &mut Session, _: &Value) -> CmdResult {
|
||||
match target(s) {
|
||||
match selected_text_box(s).or_else(|| target(s)) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, &|c| *c = c.cleared())?;
|
||||
// Clear Formatting also resets the paragraph to Normal.
|
||||
@@ -652,7 +672,7 @@ fn clear(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
/// Remove direct character formatting, keeping the paragraph and any character style.
|
||||
fn reset_char(s: &mut Session, _: &Value) -> CmdResult {
|
||||
match target(s) {
|
||||
match selected_text_box(s).or_else(|| target(s)) {
|
||||
Some((a, b)) => s.doc.format_range(&a, &b, &|c| *c = CharProps { style: c.style.clone(), ..c.cleared() })?,
|
||||
None => s.pending = Some(CharProps::default()),
|
||||
}
|
||||
@@ -684,7 +704,9 @@ pub fn state(s: &Session) -> Value {
|
||||
let v0 = f(r);
|
||||
if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null }
|
||||
};
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
|
||||
// A selected text box shows its own (first) paragraph's style, like its text's formatting.
|
||||
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
|
||||
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
|
||||
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
|
||||
json!({
|
||||
"font": same(&|r| json!(r.font)),
|
||||
|
||||
@@ -221,7 +221,7 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
s.ui_requests.push(json!({"open": "insertPicture"}));
|
||||
s.push_ui_request(json!({"open": "insertPicture"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
@@ -295,7 +295,7 @@ fn icon(s: &mut Session, v: &Value) -> CmdResult {
|
||||
.iter()
|
||||
.map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords}))
|
||||
.collect();
|
||||
s.ui_requests.push(json!({"open": "icons"}));
|
||||
s.push_ui_request(json!({"open": "icons"}));
|
||||
return Ok(json!({"icons": icons}));
|
||||
};
|
||||
let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?;
|
||||
@@ -441,7 +441,7 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
None => match place_target(s, v)? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
s.ui_requests.push(json!({"open": "link"}));
|
||||
s.push_ui_request(json!({"open": "link"}));
|
||||
return sel_result(s);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,7 +122,13 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
"paragraphFormatting" => {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
|
||||
*p = ParaProps {
|
||||
style: p.style.clone(),
|
||||
numbering: p.numbering,
|
||||
num_change: p.num_change.take(),
|
||||
fmt_change: p.fmt_change.take(),
|
||||
..Default::default()
|
||||
};
|
||||
})?;
|
||||
}
|
||||
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
|
||||
@@ -136,6 +142,7 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
|
||||
lang: c.lang.clone(),
|
||||
no_proof: c.no_proof,
|
||||
rtl: c.rtl,
|
||||
fmt_change: c.fmt_change.take(),
|
||||
..Default::default()
|
||||
};
|
||||
});
|
||||
|
||||
@@ -439,6 +439,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.when(has_recipients),
|
||||
CommandSpec::new("mailings.next", "Next Record", "Mailings › Preview Results", |s, _| step(s, 1)).when(has_recipients),
|
||||
CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients),
|
||||
CommandSpec::new("mailings.first", "First Record", "Mailings › Preview Results", |s, _| go_to(s, 0)).when(has_recipients),
|
||||
CommandSpec::new("mailings.last", "Last Record", "Mailings › Preview Results", |s, _| go_to(s, i64::MAX)).when(has_recipients),
|
||||
CommandSpec::new("mailings.goToRecord", "Go to Record", "Mailings › Preview Results", |s, v| {
|
||||
let n = v.get("record").and_then(|r| r.as_i64().or_else(|| r.as_str().and_then(|t| t.trim().parse().ok())));
|
||||
let n = n.ok_or_else(|| CmdError::Params("`record` must be a record number (1 = the first)".into()))?;
|
||||
go_to(s, n.saturating_sub(1))
|
||||
})
|
||||
.params(r#"{"record": n (1 = the first; past either end goes to the first or last record)}"#)
|
||||
.when(has_recipients),
|
||||
CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| {
|
||||
let q = p::req_str(v, "text")?.to_lowercase();
|
||||
let idx = s
|
||||
@@ -464,9 +473,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish)
|
||||
.params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#)
|
||||
.params(r#"{"path"?: string (save the merged document), "records"?: "all|current" (current: the previewed record; default all, or from/to), "from"?: n, "to"?: n (record numbers, 1 = the first)}"#)
|
||||
.key(super::non_mac("Alt+Shift+N"))
|
||||
.when(has_recipients),
|
||||
CommandSpec::new("mailings.printDocuments", "Print Documents", "Mailings › Finish › Finish & Merge", |s, v| {
|
||||
let mut v = v.clone();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("path");
|
||||
}
|
||||
let r = finish(s, &v)?;
|
||||
s.push_ui_request(json!({"open": "print"}));
|
||||
Ok(r)
|
||||
})
|
||||
.params(r#"{"records"?: "all|current", "from"?: n, "to"?: n} (merges into a new document, as Finish & Merge does, and opens Print for it)"#)
|
||||
.key(super::non_mac("Alt+Shift+M"))
|
||||
.when(has_recipients),
|
||||
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params(
|
||||
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
|
||||
),
|
||||
@@ -724,8 +745,13 @@ fn header_split(mut all: Vec<Vec<String>>, headers: bool) -> Result<(Vec<String>
|
||||
}
|
||||
|
||||
fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
go_to(s, (s.merge.record as i64).saturating_add(d))
|
||||
}
|
||||
|
||||
/// Preview record `idx` (0-based), clamped to the list.
|
||||
fn go_to(s: &mut Session, idx: i64) -> CmdResult {
|
||||
let n = s.merge.rows.len().max(1) as i64;
|
||||
s.merge.record = (s.merge.record as i64 + d).clamp(0, n - 1) as usize;
|
||||
s.merge.record = idx.clamp(0, n - 1) as usize;
|
||||
s.merge.preview = true;
|
||||
refresh(s);
|
||||
Ok(json!({"record": s.merge.record + 1, "of": n}))
|
||||
@@ -933,6 +959,23 @@ pub fn refresh(s: &mut Session) {
|
||||
s.touch();
|
||||
}
|
||||
|
||||
/// The records Finish & Merge and Print Documents take (0-based, end exclusive): `records`
|
||||
/// all or current, or `from` and `to` by record number. A range with none is an error.
|
||||
pub fn merge_range(s: &Session, v: &Value) -> Result<(usize, usize), CmdError> {
|
||||
let n = s.merge.rows.len();
|
||||
let (from, to) = match p::str(v, "records").map(str::trim) {
|
||||
Some("current") => (s.merge.record.min(n.saturating_sub(1)), s.merge.record.saturating_add(1).min(n)),
|
||||
None | Some("all") => {
|
||||
((p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1), (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize)
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`records` must be all or current".into())),
|
||||
};
|
||||
if from >= to {
|
||||
return Err(CmdError::Failed(format!("no records from {} to {to} (the list has {n})", from.saturating_add(1))));
|
||||
}
|
||||
Ok((from, to))
|
||||
}
|
||||
|
||||
/// Most merged documents' worth of body blocks Finish & Merge builds.
|
||||
const MAX_MERGED_BLOCKS: usize = 200_000;
|
||||
|
||||
@@ -942,9 +985,7 @@ const MAX_MERGED_BLOCKS: usize = 200_000;
|
||||
/// are merged too: with merge fields in them, each letter becomes its own section with its own
|
||||
/// header/footer copies.
|
||||
fn finish(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let n = s.merge.rows.len();
|
||||
let from = (p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1);
|
||||
let to = (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize;
|
||||
let (from, to) = merge_range(s, v)?;
|
||||
let directory = s.merge.kind == "directory";
|
||||
let hf: Vec<u32> =
|
||||
header_footer_ids(&s.doc).into_iter().filter(|id| s.doc.story(StoryRef::Part(*id)).is_some_and(|b| has_merge_fields(b, 0))).collect();
|
||||
@@ -1580,6 +1621,65 @@ mod tests {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha / Beta\nGamma / ");
|
||||
}
|
||||
|
||||
/// #3028: First, Last and Go to Record move the preview; Finish & Merge and Print Documents
|
||||
/// take all records, the current one, or a range by record number.
|
||||
#[test]
|
||||
fn record_navigation_and_merge_ranges() {
|
||||
let names = ["Alpha", "Beta", "Gamma", "Delta"];
|
||||
let fresh = || {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": names.map(|n| json!({"Name": n}))})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
|
||||
s
|
||||
};
|
||||
let mut s = fresh();
|
||||
let body = |s: &Session| s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(s.run("mailings.last", &json!({})).unwrap(), json!({"record": 4, "of": 4}));
|
||||
assert_eq!(body(&s), "Delta");
|
||||
assert_eq!(s.run("mailings.first", &json!({})).unwrap()["record"], 1);
|
||||
assert_eq!(body(&s), "Alpha");
|
||||
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 3})).unwrap()["record"], 3);
|
||||
assert_eq!(body(&s), "Gamma");
|
||||
assert_eq!(s.run("mailings.goToRecord", &json!({"record": " 2 "})).unwrap()["record"], 2);
|
||||
// Past either end: the last or first record, as the step buttons do.
|
||||
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 1141})).unwrap()["record"], 4);
|
||||
assert_eq!(s.run("mailings.goToRecord", &json!({"record": -5})).unwrap()["record"], 1);
|
||||
for bad in [json!({}), json!({"record": "two"}), json!({"record": 2.5})] {
|
||||
assert!(matches!(s.run("mailings.goToRecord", &bad), Err(CmdError::Params(_))), "{bad}");
|
||||
}
|
||||
|
||||
s.run("mailings.goToRecord", &json!({"record": 3})).unwrap();
|
||||
assert_eq!(s.run("mailings.finish", &json!({"records": "current"})).unwrap()["records"], 1);
|
||||
assert_eq!(body(&s), "Gamma");
|
||||
let mut s = fresh();
|
||||
assert_eq!(s.run("mailings.finish", &json!({"from": 2, "to": 3})).unwrap()["records"], 2);
|
||||
assert_eq!(body(&s), "Beta\nGamma");
|
||||
let mut s = fresh();
|
||||
assert_eq!(s.run("mailings.finish", &json!({"records": "all", "from": 3, "to": 99})).unwrap()["records"], 2);
|
||||
assert_eq!(body(&s), "Gamma\nDelta");
|
||||
// An empty range is refused and the document is kept, not replaced by an empty one.
|
||||
let mut s = fresh();
|
||||
let before = body(&s);
|
||||
for bad in [json!({"from": 3, "to": 2}), json!({"from": 5}), json!({"records": "some"})] {
|
||||
assert!(s.run("mailings.finish", &bad).is_err(), "{bad}");
|
||||
assert_eq!(body(&s), before, "{bad}");
|
||||
}
|
||||
let e = s.run("mailings.finish", &json!({"from": 5})).unwrap_err().to_string();
|
||||
assert_eq!(e, "no records from 5 to 4 (the list has 4)", "a plain message for the dialog");
|
||||
|
||||
// Print Documents merges the same way, then asks the interface for Print.
|
||||
let mut s = fresh();
|
||||
s.ui_requests.clear();
|
||||
assert_eq!(s.run("mailings.printDocuments", &json!({"from": 4, "path": "ignored.docx"})).unwrap()["records"], 1);
|
||||
assert_eq!(body(&s), "Delta");
|
||||
assert!(s.ui_requests.contains(&json!({"open": "print"})));
|
||||
let mut s = fresh();
|
||||
s.ui_requests.clear();
|
||||
assert!(s.run("mailings.printDocuments", &json!({"from": 9})).is_err());
|
||||
assert!(s.ui_requests.is_empty(), "nothing to print, no Print dialog");
|
||||
}
|
||||
|
||||
/// #429: an IF rule shows its true / false text in Preview Results, as Finish & Merge does.
|
||||
#[test]
|
||||
fn if_rule_previews_its_result() {
|
||||
|
||||
@@ -130,7 +130,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
if sharpen > 0.0 {
|
||||
img = img.unsharpen(1.0 + sharpen / 50.0, 2);
|
||||
} else if sharpen < 0.0 {
|
||||
} else if sharpen < 0.0 && (-sharpen / 25.0).is_normal() {
|
||||
// `blur` asserts on a subnormal width.
|
||||
img = img.blur(-sharpen / 25.0);
|
||||
}
|
||||
img
|
||||
@@ -1563,6 +1564,11 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if shown.is_empty() {
|
||||
return Err(CmdError::Failed("nothing to group".into()));
|
||||
}
|
||||
// `shown` already has the members of any picked group merged in, so this counts the
|
||||
// flattened members. Readers stop at the cap and would drop the rest.
|
||||
if shown.len() > wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return Err(CmdError::Disabled(format!("too many objects to group (at most {})", wordcraft_doc::para::MAX_GROUP_CHILDREN)));
|
||||
}
|
||||
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
|
||||
// Each member keeps its turn: the group itself starts unturned.
|
||||
let children: Vec<GroupChild> = shown
|
||||
@@ -2010,6 +2016,17 @@ mod tests {
|
||||
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
|
||||
}
|
||||
|
||||
/// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it.
|
||||
#[test]
|
||||
fn a_tiny_sharpen_value_does_not_panic() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] {
|
||||
s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency is absolute from the original pixels: 80% then 0% is opaque again
|
||||
/// (it used to stay faded), and repeating a value is idempotent. Corrections and color
|
||||
/// re-set the same way: brightness 0 and saturation 100 restore the original.
|
||||
@@ -2232,6 +2249,50 @@ mod tests {
|
||||
assert_eq!(all_objects(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// Select the objects at `at` in the list of all objects (the first as the selection, the rest added).
|
||||
fn pick_objects(s: &mut Session, at: &[usize]) {
|
||||
let list = all_objects(s);
|
||||
let first = &list[at[0]].0;
|
||||
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + wordcraft_doc::para::OBJ.len_utf8(), ..first.clone() } };
|
||||
s.also_selected = at[1..].iter().map(|i| list[*i].0.clone()).collect();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_refuses_more_than_the_child_cap() {
|
||||
let cap = wordcraft_doc::para::MAX_GROUP_CHILDREN;
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "one"})).unwrap();
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
for _ in 0..=cap {
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 10, "height": 10})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 100})).unwrap();
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
}
|
||||
let n = all_objects(&s).len();
|
||||
assert_eq!(n, cap + 1);
|
||||
|
||||
// Flat: every shape picked.
|
||||
pick_objects(&mut s, &(0..n).collect::<Vec<_>>());
|
||||
assert_eq!(picked_objects(&s).len(), n);
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
|
||||
// Nested: a group of cap - 1 and a group of 2; grouping those two would hold cap + 1 members.
|
||||
pick_objects(&mut s, &(0..cap - 1).collect::<Vec<_>>());
|
||||
s.run("arrange.group", &json!({})).unwrap();
|
||||
let rest = all_objects(&s).len();
|
||||
assert_eq!(rest, 3);
|
||||
pick_objects(&mut s, &[1, 2]);
|
||||
s.run("arrange.group", &json!({})).unwrap();
|
||||
assert_eq!(all_objects(&s).len(), 2);
|
||||
pick_objects(&mut s, &[0, 1]);
|
||||
assert_eq!(picked_objects(&s).len(), 2);
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
/// Page 1 as exported (`file.exportPng` at scale 1), its pixels.
|
||||
fn page_pixels(s: &Session) -> Vec<u8> {
|
||||
let l = s.export_layout();
|
||||
|
||||
@@ -201,7 +201,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Marks, hidden text and proofing change what is laid out.
|
||||
s.relayout();
|
||||
} else {
|
||||
s.ui_requests.push(json!({"open": "options", "pane": pane}));
|
||||
s.push_ui_request(json!({"open": "options", "pane": pane}));
|
||||
}
|
||||
let mut r = current(s);
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
validate_section(&mut new)?;
|
||||
return with_sect(s, |x| *x = new.clone());
|
||||
}
|
||||
s.ui_requests.push(json!({"open": "pageSetup"}));
|
||||
s.push_ui_request(json!({"open": "pageSetup"}));
|
||||
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
|
||||
})
|
||||
.params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#),
|
||||
|
||||
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
r#"{"props": ParaProps, "onPage"?: {"align"?: "left|center|right|justify|distribute", "left"?: pt, "right"?: pt} (alignment and indents as seen on the page: each paragraph's own direction, after `props`, says which edge is its start)}"#,
|
||||
),
|
||||
CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "paragraph"}));
|
||||
s.push_ui_request(json!({"open": "paragraph"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
@@ -213,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
|
||||
}
|
||||
let list = manage_list(s, sort == "alphabetical");
|
||||
s.ui_requests.push(json!({"open": "manageStyles"}));
|
||||
s.push_ui_request(json!({"open": "manageStyles"}));
|
||||
Ok(list)
|
||||
})
|
||||
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
|
||||
|
||||
@@ -106,7 +106,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
}
|
||||
s.ui_requests.push(req);
|
||||
// Only the newest dialog request matters; older ones would pin their payloads.
|
||||
s.ui_requests.retain(|r| r["open"] != "pasteSpecial");
|
||||
s.push_ui_request(req);
|
||||
return Ok(json!({"formats": formats_json(&ids)}));
|
||||
};
|
||||
let fmt = match fmt.to_ascii_lowercase().as_str() {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`).
|
||||
fn phonetic(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(rv) = v.get("ruby") else {
|
||||
s.ui_requests.push(json!({"open": "phonetic"}));
|
||||
s.push_ui_request(json!({"open": "phonetic"}));
|
||||
return Ok(state(s));
|
||||
};
|
||||
let text: String = match rv {
|
||||
|
||||
@@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool {
|
||||
doc.settings.protection.is_some()
|
||||
}
|
||||
|
||||
/// Is the document's editing restricted? The formatting-only protection stores the mode "none"
|
||||
/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it
|
||||
/// limits the styles, not the editing.
|
||||
pub fn editing_restricted(doc: &Document) -> bool {
|
||||
doc.settings.protection.as_deref().is_some_and(|m| m != "none")
|
||||
}
|
||||
|
||||
/// May `author` edit inside the range?
|
||||
fn editable_by(r: &PermRange, author: &str) -> bool {
|
||||
r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim()))
|
||||
@@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms")));
|
||||
}
|
||||
let formatting = p::bool(v, "formatting");
|
||||
let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true));
|
||||
let explicit_stop = p::bool(v, "stop") == Some(true);
|
||||
// Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a
|
||||
// protected document only `{"stop": true}` stops it — a command that merely names a mode
|
||||
// can't strip a user's protection (Word asks for Stop Protection first).
|
||||
if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) {
|
||||
return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into()));
|
||||
}
|
||||
let stop = explicit_stop || (mode == Some("none") && formatting != Some(true));
|
||||
if stop {
|
||||
return stop_protection(s, p::str(v, "password").unwrap_or(""));
|
||||
}
|
||||
@@ -90,8 +104,16 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Only the locked styles changed (or nothing): report the state.
|
||||
return Ok(state(s));
|
||||
}
|
||||
if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() {
|
||||
return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into()));
|
||||
// The protection of a protected document doesn't change: Word asks for Stop Protection first.
|
||||
// (The formatting-only protection isn't one: it limits the styles, not the editing — but a
|
||||
// password on it still guards it, so it can't be replaced either.)
|
||||
if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() {
|
||||
let why = if s.doc.settings.protect_hash.is_empty() {
|
||||
"the document is protected: stop protection first"
|
||||
} else {
|
||||
"the document is protected with a password: stop protection first"
|
||||
};
|
||||
return Err(CmdError::Disabled(why.into()));
|
||||
}
|
||||
let formatting = formatting.unwrap_or(false);
|
||||
let mode = mode.unwrap_or("none");
|
||||
@@ -202,24 +224,92 @@ const CONTROL_INSERTS: [&str; 9] = [
|
||||
"developer.repeatingSection",
|
||||
];
|
||||
|
||||
/// Commands always allowed in a protected document: protection itself, file commands, undo.
|
||||
fn always_allowed(id: &str) -> bool {
|
||||
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
|
||||
/// Commands always allowed in a protected document: protection itself, undo, and the file
|
||||
/// commands that don't change the open document. A `file.*` command that does change it is
|
||||
/// checked like any other, so the protection holds for it too: the privacy options and the
|
||||
/// properties are document settings, and the inspector removes content (#1327 follow-up).
|
||||
fn always_allowed(id: &str, params: &Value) -> bool {
|
||||
if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" {
|
||||
return true;
|
||||
}
|
||||
if !id.starts_with("file.") {
|
||||
return false;
|
||||
}
|
||||
match id {
|
||||
"file.privacyOptions" | "file.properties" => false,
|
||||
// The user name says who may edit the document and who the revisions belong to: it is
|
||||
// checked like any other change, not waved through.
|
||||
"file.setAuthor" => false,
|
||||
// The inspector reads the document; its removals change it.
|
||||
"file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user name (`file.setAuthor`) is the identity a named exception range is checked against,
|
||||
/// and the author recorded on new revisions and comments: it can't change while the document is
|
||||
/// protected. Word lets the user name change under protection (it is a preference there); this
|
||||
/// project doesn't, because the name says who may edit what (#1327 follow-up).
|
||||
pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> {
|
||||
if enforced(&s.doc) {
|
||||
return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A version restore replaces the whole document, including the content the protection kept
|
||||
/// read-only, so it needs the protection stopped first — and the password with it, when the
|
||||
/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the
|
||||
/// command checks this itself — see [`check_version_restore`].)
|
||||
pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> {
|
||||
if enforced(&s.doc) {
|
||||
return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What decides a protected document's outright refusals: whether the current author may edit
|
||||
/// any exception (`w:permStart`), and how many content controls take entries. Both walk the
|
||||
/// whole document.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Facts {
|
||||
pub exceptions: bool,
|
||||
pub fillable: usize,
|
||||
}
|
||||
|
||||
impl Facts {
|
||||
pub(crate) fn of(s: &Session) -> Facts {
|
||||
Facts { exceptions: s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author)), fillable: s.doc.fillable_controls() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
|
||||
/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame.
|
||||
/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons
|
||||
/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control.
|
||||
/// The form's fields stay as they are; only their content is filled in.
|
||||
pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
|
||||
if s.doc.settings.protection.as_deref() != Some("forms") {
|
||||
/// it out ([`Session::why_disabled`], with [`Facts`] cached per document revision) and
|
||||
/// [`check_before`] refuses it. Observed in Word for Mac: with Filling in forms enforced, the
|
||||
/// Developer tab's control buttons and Properties are greyed out and a field's shortcut menu
|
||||
/// offers no Remove Content Control. The form's fields stay as they are; only their content is
|
||||
/// filled in. A document that is read only (or open to comments only) with no exception the
|
||||
/// author may edit takes no other edits at all.
|
||||
pub(crate) fn greyed_out(s: &Session, id: &str, facts: impl FnOnce() -> Facts) -> Option<&'static str> {
|
||||
let mode = s.doc.settings.protection.as_deref()?;
|
||||
if always_allowed(id, &Value::Null) {
|
||||
return None;
|
||||
}
|
||||
match id {
|
||||
"developer.removeControl" => Some("stop protection before removing a content control"),
|
||||
"developer.properties" => Some("stop protection before changing a content control's properties"),
|
||||
_ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"),
|
||||
if mode == "forms" {
|
||||
match id {
|
||||
"developer.removeControl" => return Some("stop protection before removing a content control"),
|
||||
"developer.properties" => return Some("stop protection before changing a content control's properties"),
|
||||
_ if CONTROL_INSERTS.contains(&id) => return Some("stop protection before inserting a content control"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
|
||||
let tracked = matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges");
|
||||
match mode {
|
||||
"readOnly" => (!facts().exceptions).then_some("the document is protected (read only)"),
|
||||
"forms" => (facts().fillable == 0).then_some("the document is protected (read only)"),
|
||||
"comments" if !comment_ok => (!facts().exceptions).then_some("only comments are allowed in this document"),
|
||||
"trackedChanges" if tracked => Some("changes to this document are tracked"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -229,9 +319,16 @@ pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
|
||||
/// [`outside_unchanged`]).
|
||||
pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool, CmdError> {
|
||||
let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) };
|
||||
if always_allowed(id) {
|
||||
if always_allowed(id, params) {
|
||||
return Ok(false);
|
||||
}
|
||||
// The user name says who may edit the document, and who the new revisions and comments
|
||||
// belong to: it can't change while the document is protected. (`file.setAuthor` is a pure
|
||||
// command, so `check_before` doesn't see it; the command checks this itself — see
|
||||
// [`check_user_name`].)
|
||||
if id == "file.setAuthor" {
|
||||
check_user_name(s)?;
|
||||
}
|
||||
// Word blocks content-control property edits until Stop Protection: changing a control's
|
||||
// lock flags would reshape what forms mode lets through. The param's presence is the
|
||||
// trigger — absent, the command keeps the current lock (as `check_formatting` inspects
|
||||
@@ -240,25 +337,35 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
|
||||
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
|
||||
}
|
||||
if let Some(why) = greyed_out(s, id) {
|
||||
// The document's properties and its privacy options are document-wide state, and they aren't
|
||||
// tracked: a document that tracks changes would take them with no revision to show for it, and
|
||||
// the revision trail would say nothing about them. They're refused while the document is
|
||||
// protected, as the content control's properties are (#3278 follow-up).
|
||||
// The inspector's removals take content out of the document — the comments, the revisions,
|
||||
// the properties, the hidden text, the headers — so they are refused in every mode too: under
|
||||
// tracked changes they would change the document with no revision to show for it, and under
|
||||
// the other modes the document may not change at all. (An inspection without removals only
|
||||
// reads the document, and is let through by `always_allowed`.)
|
||||
if enforced(&s.doc) {
|
||||
match id {
|
||||
"file.properties" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the document's properties"))),
|
||||
"file.privacyOptions" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the privacy options"))),
|
||||
"file.inspect" => return Err(CmdError::Disabled(format!("{id}: stop protection before removing content with the inspector"))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(why) = greyed_out(s, id, || Facts::of(s)) {
|
||||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||||
}
|
||||
if s.doc.settings.protect_formatting {
|
||||
check_formatting(s, id, params)?;
|
||||
}
|
||||
// Not refused outright: read only and comments mode have an exception this author may edit,
|
||||
// forms mode has fillable controls. Comments are always allowed in comments mode.
|
||||
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
|
||||
let exceptions = || s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author));
|
||||
match mode {
|
||||
"readOnly" if exceptions() => Ok(true),
|
||||
// Filling in forms: the interiors of controls that allow it stay editable.
|
||||
"forms" if s.doc.fillable_controls() > 0 => Ok(true),
|
||||
"readOnly" | "forms" => Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
|
||||
"comments" if comment_ok => Ok(false),
|
||||
"comments" if exceptions() => Ok(true),
|
||||
"comments" => Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
|
||||
"trackedChanges" if matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges") => {
|
||||
Err(CmdError::Disabled(format!("{id}: changes to this document are tracked")))
|
||||
}
|
||||
"readOnly" | "forms" | "comments" => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
@@ -320,7 +427,7 @@ enum HoleMode {
|
||||
/// run formatting and paragraph formatting outside the ranges `author` may edit, in every story,
|
||||
/// and the formatting of every table, row and cell that holds any of that protected content, and what
|
||||
/// reaches the whole document: page setup, the document defaults and the existing styles
|
||||
/// (applying a style may add one).
|
||||
/// (applying a style may add one), the document's settings and its metadata.
|
||||
pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool {
|
||||
// Protection can't change mid-command (`protection_lifted` guards undo and redo).
|
||||
let mode = if Some("forms") == before.settings.protection.as_deref() { HoleMode::Controls } else { HoleMode::Perms(author.into()) };
|
||||
@@ -330,9 +437,35 @@ pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &st
|
||||
&& b.default_chr == a.default_chr
|
||||
&& b.default_para == a.default_para
|
||||
&& b.styles.iter().all(|st| a.get(&st.id) == Some(st))
|
||||
&& before.settings == after.settings
|
||||
&& before.core == after.core
|
||||
&& before.custom_props == after.custom_props
|
||||
&& comments_unchanged(before, after)
|
||||
&& lists_unchanged(before, after)
|
||||
&& sources_unchanged(before, after)
|
||||
&& outside(before, &mode) == outside(after, &mode)
|
||||
}
|
||||
|
||||
/// The comments' resolved flags: a comment's text is a part, and the parts are compared with the
|
||||
/// rest of the document; the flag is the comment's own state.
|
||||
fn comments_unchanged(before: &Document, after: &Document) -> bool {
|
||||
before.comments.iter().all(|(id, c)| after.comments.get(id).is_some_and(|a| a.resolved == c.resolved))
|
||||
}
|
||||
|
||||
/// The list definitions: the ones the document had must be unchanged — a list's format reaches
|
||||
/// every paragraph that uses it — but a new one may be added, like a new style: applying a list
|
||||
/// inside an editable region adds its definition.
|
||||
fn lists_unchanged(before: &Document, after: &Document) -> bool {
|
||||
before.numbering.abstracts.iter().all(|x| after.numbering.abstracts.contains(x))
|
||||
&& before.numbering.nums.iter().all(|x| after.numbering.nums.contains(x))
|
||||
}
|
||||
|
||||
/// The bibliography sources: the ones the document had must be unchanged; a new one may be added
|
||||
/// (a citation inserted inside an editable region).
|
||||
fn sources_unchanged(before: &Document, after: &Document) -> bool {
|
||||
before.sources.iter().all(|x| after.sources.contains(x))
|
||||
}
|
||||
|
||||
/// The section properties paragraphs end, in order.
|
||||
fn sections(doc: &Document) -> Vec<&wordcraft_doc::SectionProps> {
|
||||
doc.para_paths(StoryRef::Body).iter().filter_map(|path| doc.para(StoryRef::Body, path)?.section.as_deref()).collect()
|
||||
@@ -693,6 +826,216 @@ mod tests {
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
/// A version restore replaces the whole document, including the content the protection kept
|
||||
/// read-only, so it needs the protection stopped first — and the password with it.
|
||||
#[test]
|
||||
fn a_version_restore_needs_the_protection_stopped_first() {
|
||||
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
|
||||
let mut s = Session::new(Document::from_text("Original text."));
|
||||
s.run("file.versions", &json!({"save": "before"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": mode, "password": "pw"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("file.versions", &json!({"restore": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{mode}: {e}");
|
||||
assert_eq!(s.doc, before, "{mode}: the restore changed the document");
|
||||
assert_eq!(s.versions.len(), 1, "{mode}: the snapshot is still there");
|
||||
}
|
||||
}
|
||||
|
||||
/// A restore never changes the protection: the live document's choice stands, and a
|
||||
/// snapshot taken while the document was protected doesn't bring that protection back.
|
||||
#[test]
|
||||
fn a_version_restore_keeps_the_live_protection() {
|
||||
let mut s = Session::new(Document::from_text("Original text."));
|
||||
s.run("file.versions", &json!({"save": "before"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
|
||||
// The protection is stopped, with its password, and the version restored after that.
|
||||
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
|
||||
assert!(s.doc.settings.protection.is_none());
|
||||
s.run("file.versions", &json!({"restore": 0})).unwrap();
|
||||
assert!(s.doc.settings.protection.is_none(), "the restore brought the protection back");
|
||||
assert!(s.doc.settings.protect_hash.is_empty(), "the restore brought the password back");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Original text.");
|
||||
}
|
||||
|
||||
/// A document-wide change that isn't tracked — the properties, the privacy options — is
|
||||
/// refused while the document is protected: under tracked changes it would change the
|
||||
/// document with no revision to show for it, and under the other modes the document may not
|
||||
/// change at all.
|
||||
#[test]
|
||||
fn a_document_wide_change_that_is_not_tracked_is_refused_under_protection() {
|
||||
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
|
||||
for (id, params) in [("file.properties", json!({"title": "Forged title"})), ("file.privacyOptions", json!({"removePersonalInfo": true}))]
|
||||
{
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": mode})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let e = s.run(id, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{mode} {id}: {e}");
|
||||
assert_eq!(s.doc, before, "{mode} {id}: the document changed");
|
||||
}
|
||||
}
|
||||
// Without protection they work.
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("file.properties", &json!({"title": "A title"})).unwrap();
|
||||
assert_eq!(s.doc.core.title, "A title");
|
||||
s.run("file.privacyOptions", &json!({"removePersonalInfo": true})).unwrap();
|
||||
assert!(s.doc.settings.remove_personal_info);
|
||||
}
|
||||
|
||||
/// A file command that changes the document is checked like any other: the privacy options,
|
||||
/// the properties and the inspector's removals are refused in every mode a protected document
|
||||
/// can be in (#1327 follow-up).
|
||||
#[test]
|
||||
fn file_commands_that_change_the_document_are_protected() {
|
||||
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
|
||||
for (id, params) in [
|
||||
("file.privacyOptions", json!({"removePersonalInfo": true})),
|
||||
("file.properties", json!({"title": "New title"})),
|
||||
("file.inspect", json!({"remove": ["comments"]})),
|
||||
] {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": mode})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run(id, ¶ms).is_err(), "{mode} {id}: a protected document took the change");
|
||||
assert_eq!(s.doc, before, "{mode} {id}");
|
||||
}
|
||||
}
|
||||
// Without protection they work.
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("file.inspect", &json!({"remove": ["comments"]})).unwrap();
|
||||
}
|
||||
|
||||
/// The user name is the identity a named exception range is checked against, and the author
|
||||
/// the new revisions and comments are recorded under: a protected document refuses to change
|
||||
/// it (#1327 follow-up).
|
||||
#[test]
|
||||
fn the_user_name_cannot_change_under_protection() {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let author = s.author.clone();
|
||||
let e = s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.author, author, "the user name did not change");
|
||||
// Without protection it works again.
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap();
|
||||
assert_eq!(s.author, "Alice");
|
||||
}
|
||||
|
||||
/// The protection of a protected document doesn't change: Word asks for Stop Protection first,
|
||||
/// and only the explicit way stops it — a command that merely names a mode can't strip it
|
||||
/// (#1327 follow-up).
|
||||
#[test]
|
||||
fn a_protected_documents_protection_needs_an_explicit_stop() {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
// The "none" shorthand stops the protection: refused, and the protection stays.
|
||||
let e = s.run("file.protect", &json!({"mode": "none"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
|
||||
// Changing the mode of a protected document is refused too.
|
||||
let e = s.run("review.restrict", &json!({"mode": "comments"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
|
||||
// Stopping it explicitly works.
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
assert!(s.doc.settings.protection.is_none());
|
||||
}
|
||||
|
||||
/// A password-protected document still needs its password to stop, and the formatting-only
|
||||
/// protection (the mode "none") is still how the styles are limited.
|
||||
#[test]
|
||||
fn a_password_guards_the_protection_and_the_formatting_only_path_still_works() {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
|
||||
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
|
||||
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
|
||||
// The formatting-only protection: the mode "none" with the formatting restriction.
|
||||
let mut t = Session::new(Document::from_text("Some text."));
|
||||
t.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap();
|
||||
assert_eq!(t.doc.settings.protection.as_deref(), Some("none"));
|
||||
assert!(t.doc.settings.protect_formatting);
|
||||
}
|
||||
|
||||
/// A password guards the protection whatever its mode: a formatting-only protection with a
|
||||
/// password can't have that password replaced (and then removed) without knowing it.
|
||||
#[test]
|
||||
fn a_password_guards_the_formatting_only_protection_too() {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": "none", "formatting": true, "password": "pw"})).unwrap();
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"));
|
||||
let hash = s.doc.settings.protect_hash.clone();
|
||||
assert!(!hash.is_empty(), "the password is set");
|
||||
// Replacing the protection would replace the password with one of the caller's choosing.
|
||||
assert!(s.run("review.restrict", &json!({"mode": "readOnly", "password": "other"})).is_err());
|
||||
assert_eq!(s.doc.settings.protect_hash, hash, "the password was replaced");
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was replaced");
|
||||
// And stopping it needs the password.
|
||||
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
|
||||
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was stopped");
|
||||
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
|
||||
assert!(s.doc.settings.protection.is_none());
|
||||
}
|
||||
|
||||
/// The settings and the metadata of a protected document are part of what stays as it was:
|
||||
/// a command that changes them is refused, or rolled back, like any other change outside
|
||||
/// the editable regions (#1327 follow-up).
|
||||
#[test]
|
||||
fn a_protected_document_keeps_its_settings_and_metadata() {
|
||||
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
|
||||
select(&mut s, "Fill in here.");
|
||||
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
// The properties: the change is refused, and the command says why.
|
||||
let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_ne!(s.doc.core.title, "Forged title");
|
||||
// A custom property too.
|
||||
assert!(s.run("file.properties", &json!({"custom": {"Owner": "Alice"}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
// An edit inside the exception still works, and isn't rolled back.
|
||||
select(&mut s, "here");
|
||||
s.run("text.insert", &json!({"text": "there"})).unwrap();
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("Fill in there."));
|
||||
// Without protection the same command works.
|
||||
let mut t = Session::new(Document::from_text("Some text."));
|
||||
t.run("file.properties", &json!({"title": "A title"})).unwrap();
|
||||
assert_eq!(t.doc.core.title, "A title");
|
||||
}
|
||||
|
||||
/// Reading the document stays allowed under protection: the inspector without removals, the
|
||||
/// privacy settings, the info, and saving a version snapshot.
|
||||
#[test]
|
||||
fn file_commands_that_only_read_stay_allowed() {
|
||||
let mut s = Session::new(Document::from_text("Some text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
for (id, params) in
|
||||
[("file.inspect", json!({})), ("file.privacy", json!({})), ("file.info", json!({})), ("file.versions", json!({"save": "snapshot"}))]
|
||||
{
|
||||
assert!(s.run(id, ¶ms).is_ok(), "{id}: a protected document refused a read");
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-check compares the document's settings and its metadata, so a command that
|
||||
/// changed either is rolled back even where the command itself isn't refused — the
|
||||
/// backstop behind the refusal the properties and the privacy options get.
|
||||
#[test]
|
||||
fn the_post_check_compares_the_settings_and_the_metadata() {
|
||||
let before = Document::from_text("Some text.");
|
||||
let mut after = before.clone();
|
||||
after.core.title = "Forged".into();
|
||||
assert!(!super::outside_unchanged(&before, &after, "someone"), "the metadata isn't compared");
|
||||
let mut after = before.clone();
|
||||
after.settings.remove_personal_info = true;
|
||||
assert!(!super::outside_unchanged(&before, &after, "someone"), "the settings aren't compared");
|
||||
assert!(super::outside_unchanged(&before, &before.clone(), "someone"), "an unchanged document isn't unchanged");
|
||||
}
|
||||
|
||||
/// Read only, no exceptions: going to a header the document doesn't have doesn't create
|
||||
/// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results
|
||||
/// (both used to change the document from a non-mutating command, #1327).
|
||||
@@ -772,6 +1115,59 @@ mod tests {
|
||||
assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
|
||||
}
|
||||
|
||||
/// Read only and comments-only protection grey out every edit when the document has no
|
||||
/// exception the author may edit, and none once it has one (the selection decides then).
|
||||
/// Comments stay available in comments mode (not in read only); file commands, Restrict Editing
|
||||
/// and moving the caret always do.
|
||||
#[test]
|
||||
fn read_only_and_comments_grey_out_edits_without_an_exception() {
|
||||
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
for id in ["text.insert", "format.bold", "para.alignCenter", "review.newComment"] {
|
||||
assert_eq!(s.why_disabled(id), Some("the document is protected (read only)"), "{id}");
|
||||
}
|
||||
for id in ["review.restrict", "file.save", "caret.right"] {
|
||||
assert_eq!(s.why_disabled(id), None, "{id}");
|
||||
}
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "comments"})).unwrap();
|
||||
assert_eq!(s.why_disabled("text.insert"), Some("only comments are allowed in this document"));
|
||||
assert_eq!(s.why_disabled("review.newComment"), None);
|
||||
// Tracked changes: typing stays live, accepting or rejecting the changes doesn't.
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "trackedChanges"})).unwrap();
|
||||
assert_eq!(s.why_disabled("text.insert"), None);
|
||||
assert_eq!(s.why_disabled("review.acceptAll"), Some("changes to this document are tracked"));
|
||||
// An exception for everyone: edits are no longer refused outright (the cached facts follow
|
||||
// the edit that added it).
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
select(&mut s, "Fill in here.");
|
||||
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
assert_eq!(s.why_disabled("text.insert"), None);
|
||||
assert_eq!(s.why_disabled("format.bold"), None);
|
||||
}
|
||||
|
||||
/// Track Changes Options stays available (its options are the user's own), but tracking
|
||||
/// moves is the document's setting: protection refuses changing it, even with an exception.
|
||||
#[test]
|
||||
fn protection_refuses_changing_track_moves_but_not_the_view_options() {
|
||||
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
|
||||
select(&mut s, "Fill in here.");
|
||||
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
|
||||
for mode in ["readOnly", "comments", "forms"] {
|
||||
s.run("review.restrict", &json!({"mode": mode})).unwrap();
|
||||
assert_eq!(s.why_disabled("review.trackingOptions"), None, "{mode}");
|
||||
assert!(s.run("review.trackingOptions", &json!({"trackMoves": false})).is_err(), "{mode}");
|
||||
assert!(s.doc.settings.track_moves, "{mode}");
|
||||
s.run("review.trackingOptions", &json!({"insertColor": "00AA00"})).unwrap();
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
}
|
||||
s.run("review.restrict", &json!({"mode": "trackedChanges"})).unwrap();
|
||||
s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap();
|
||||
assert!(!s.doc.settings.track_moves);
|
||||
}
|
||||
|
||||
/// The ribbon greys out what forms mode refuses outright (as Word for Mac does), and only that:
|
||||
/// typing into the field and the rest of the ribbon stay live.
|
||||
#[test]
|
||||
@@ -1002,6 +1398,8 @@ mod tests {
|
||||
assert_eq!(start, first.content_start(), "wraps round to the first control");
|
||||
assert_eq!(end, first.end);
|
||||
// Without forms mode, Find Next Region is about perm ranges: none here, nothing to find.
|
||||
// (The protection is stopped first: the mode of a protected document doesn't change.)
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
assert_eq!(next_region(&s, &s.doc.start_of(StoryRef::Body)), None);
|
||||
}
|
||||
|
||||
@@ -1042,9 +1042,17 @@ fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
m.move_to_color = color("moveToColor", "byAuthor", m.move_to_color)?;
|
||||
// Track moves is the document's (`w:doNotTrackMoves`), so changing it is an undo step; the
|
||||
// command is pure, so it records the step itself, once every parameter has been accepted.
|
||||
// The other options are the user's own, so the command stays available under Restrict
|
||||
// Editing; this one edits the document, so it passes the same check as an edit and isn't
|
||||
// limited to an exception.
|
||||
if let Some(b) = p::bool(v, "trackMoves")
|
||||
&& b != s.doc.settings.track_moves
|
||||
{
|
||||
if super::protect::check_before(s, "review.trackingOptions", &json!({}))? {
|
||||
return Err(CmdError::Disabled(
|
||||
"review.trackingOptions: the document is protected; stop protection to change whether moves are tracked".into(),
|
||||
));
|
||||
}
|
||||
s.checkpoint("Track Changes Options");
|
||||
s.doc.settings.track_moves = b;
|
||||
s.touch();
|
||||
@@ -1683,6 +1691,21 @@ mod tests {
|
||||
assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Clearing formatting in the Style Inspector keeps pending formatting revisions.
|
||||
#[test]
|
||||
fn inspector_clear_keeps_formatting_changes() {
|
||||
let mut s = formatted_while_tracking();
|
||||
s.run("review.trackChanges", &json!({"value": false})).unwrap();
|
||||
s.run("styles.inspectorClear", &json!({"level": "paragraphFormatting"})).unwrap();
|
||||
assert!(body_para(&s).props.fmt_change.is_some(), "paragraph formatting change survives");
|
||||
|
||||
let mut s = formatted_while_tracking();
|
||||
s.run("review.trackChanges", &json!({"value": false})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap();
|
||||
s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).unwrap();
|
||||
assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "character formatting change survives");
|
||||
}
|
||||
|
||||
/// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with
|
||||
/// the story each sits in.
|
||||
fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> {
|
||||
|
||||
@@ -1285,6 +1285,67 @@ pub fn text_table_cells(lines: &[String], sep: Option<char>, columns: Option<usi
|
||||
(rows, cols)
|
||||
}
|
||||
|
||||
/// The cells Convert Text to Table makes of `paras`, row by row, and the column count, as
|
||||
/// [`text_table_cells`] makes them of their text. Each cell is its field of the source paragraph
|
||||
/// cut out of it, trimmed of surrounding white space, so its formatting, objects (pictures,
|
||||
/// fields, notes…) and tracked changes go with it.
|
||||
fn text_table_paras(paras: Vec<Paragraph>, sep: Option<char>, columns: Option<usize>) -> Result<(Vec<Vec<Paragraph>>, usize), CmdError> {
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::table::MAX_COLS;
|
||||
// Each paragraph's fields as byte ranges; an object is never a separator.
|
||||
let fields: Vec<Vec<std::ops::Range<usize>>> = paras
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
if let Some(sep) = sep.filter(|c| *c != OBJ) {
|
||||
for (i, c) in p.text.char_indices().filter(|(_, c)| *c == sep) {
|
||||
out.push(from..i);
|
||||
from = i + c.len_utf8();
|
||||
}
|
||||
}
|
||||
out.push(from..p.text.len());
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let cols = match sep {
|
||||
None => columns.unwrap_or(1).clamp(1, MAX_COLS),
|
||||
Some(_) => columns.unwrap_or_else(|| fields.iter().map(Vec::len).max().unwrap_or(1)).max(1),
|
||||
};
|
||||
let per_row = cols.min(MAX_COLS);
|
||||
let mut rows: Vec<Vec<Paragraph>> = Vec::new();
|
||||
let mut row: Vec<Paragraph> = Vec::new();
|
||||
for (mut work, ranges) in paras.into_iter().zip(fields) {
|
||||
// The section break stays out of the cells.
|
||||
work.section = None;
|
||||
let mut pieces = Vec::with_capacity(ranges.len());
|
||||
// Last field first, so the earlier offsets stay valid.
|
||||
for r in ranges.into_iter().rev() {
|
||||
let field = work.text.get(r.clone()).unwrap_or("");
|
||||
let start = r.start + (field.len() - field.trim_start().len());
|
||||
let end = (r.start + field.trim_end().len()).max(start);
|
||||
let mut piece = work.split_off(start)?;
|
||||
piece.split_off(end - start)?;
|
||||
pieces.push(piece);
|
||||
}
|
||||
pieces.reverse();
|
||||
for piece in pieces {
|
||||
row.push(piece);
|
||||
if row.len() == per_row {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
}
|
||||
// With a separator, each paragraph starts a row.
|
||||
if sep.is_some() && !row.is_empty() {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
rows.push(row);
|
||||
}
|
||||
Ok((rows, cols))
|
||||
}
|
||||
|
||||
/// Word's largest Initial column width, 22 in, in points.
|
||||
const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0;
|
||||
|
||||
@@ -1316,8 +1377,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize);
|
||||
let (a, b) = s.sel.ordered();
|
||||
let lines = text_table_lines(s)?;
|
||||
let (cells, cols) = text_table_cells(&lines, sep, columns);
|
||||
// Checks the selection: only paragraphs, a table between them is refused.
|
||||
text_table_lines(s)?;
|
||||
let paras: Vec<Paragraph> = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).cloned()).collect();
|
||||
let (cells, cols) = text_table_paras(paras, sep, columns)?;
|
||||
// A table has at most 63 columns; a longer row would lose its last fields.
|
||||
if cols > wordcraft_doc::table::MAX_COLS {
|
||||
return Err(CmdError::Failed("a table can have at most 63 columns".into()));
|
||||
@@ -1333,10 +1396,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
t.grid.iter_mut().for_each(|w| *w = col_width);
|
||||
t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width));
|
||||
}
|
||||
for (r, l) in cells.iter().enumerate() {
|
||||
for (c, txt) in l.iter().enumerate() {
|
||||
for (r, l) in cells.into_iter().enumerate() {
|
||||
for (c, para) in l.into_iter().enumerate() {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
|
||||
cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))];
|
||||
cl.blocks = vec![para_block(para)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at)
|
||||
.params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt, "extend"?: bool, "anchorRow"?: n} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point; the page margin beside a table (its leading side: right for a right-to-left table) is a row area too, and `extend` (Shift+click) on one takes the rows from the selected ones' anchor, or from row `anchorRow` (0-based) when given, so a drag keeps its press's anchor. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
|
||||
@@ -129,7 +129,7 @@ fn row_at(geom: &ResizeTarget, y: f32) -> usize {
|
||||
|
||||
/// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection
|
||||
/// areas. `cells` are its cell boxes on the page.
|
||||
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option<Spot> {
|
||||
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32, bar: Option<(f32, f32)>) -> Option<Spot> {
|
||||
if on_handle(geom, x, y, reach) {
|
||||
return Some(Spot::Handle);
|
||||
}
|
||||
@@ -156,11 +156,47 @@ fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32)
|
||||
return Some(Spot::Row(r.row));
|
||||
}
|
||||
}
|
||||
// The page margin beside the table is the selection bar: a row's area too.
|
||||
if let Some((x0, x1)) = bar
|
||||
&& x >= x0
|
||||
&& x <= x1
|
||||
&& let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)
|
||||
{
|
||||
return Some(Spot::Row(r.row));
|
||||
}
|
||||
// Inside a cell, along its left edge.
|
||||
let edge = CELL_EDGE.max(reach + 2.5);
|
||||
cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell))
|
||||
}
|
||||
|
||||
/// The x range of the selection bar beside the table's row at page height `y`: the page margin
|
||||
/// on the table's leading side (left, or right for a right-to-left table). Only a non-floating
|
||||
/// table of the main text has one, and only where it is what stands beside that margin: no line
|
||||
/// of the text (another column, text wrapped round the table) lies between the two at that
|
||||
/// height. A nested table's rows are the outermost table's.
|
||||
fn row_bar(l: &DocLayout, doc: &Document, page: usize, geom: &ResizeTarget, y: f32) -> Option<(f32, f32)> {
|
||||
let p = l.pages.get(page)?;
|
||||
if geom.story != StoryRef::Body || geom.table.0.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let t = doc.table(geom.story, &geom.table)?;
|
||||
let row = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)?;
|
||||
if t.props.float.is_some() {
|
||||
return None;
|
||||
}
|
||||
let rtl = t.props.bidi_visual;
|
||||
let blocked = wordcraft_layout::hit::page_lines(p, StoryRef::Body).iter().any(|ln| {
|
||||
ln.path.0.len() <= 1
|
||||
&& ln.top < row.bottom - 0.5
|
||||
&& ln.bottom > row.top + 0.5
|
||||
&& if rtl { ln.right > geom.right() } else { ln.left < geom.left() }
|
||||
});
|
||||
if blocked {
|
||||
return None;
|
||||
}
|
||||
Some(if rtl { (geom.right().max(p.body.right()), p.w) } else { (0.0, geom.left().min(p.body.x)) })
|
||||
}
|
||||
|
||||
/// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's.
|
||||
/// None on a column or row border or the resize handle: those resize (`table_resize`). `reach`
|
||||
/// is their resize reach (points).
|
||||
@@ -178,7 +214,7 @@ pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x:
|
||||
.filter(|g| g.story == story)
|
||||
.filter_map(|geom| {
|
||||
let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect();
|
||||
spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom })
|
||||
spot_in(&geom, &cells, x, y, reach, row_bar(l, doc, page, &geom, y)).map(|spot| Target { spot, geom })
|
||||
})
|
||||
.max_by_key(|t| t.geom.table.0.len())
|
||||
}
|
||||
@@ -377,7 +413,17 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match spot {
|
||||
Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))),
|
||||
Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)),
|
||||
Spot::Row(r) => {
|
||||
let r2 = to.map_or(r, |(_, ty)| row_at(&geom, ty));
|
||||
// Shift+click: the rows from the selection's anchor row to the one clicked.
|
||||
// A drag after it keeps the anchor the press had (`anchorRow`).
|
||||
let anchor = if v.get("extend").and_then(Value::as_bool).unwrap_or(false) {
|
||||
p::u64(v, "anchorRow").map_or_else(|| anchor_row(s, &geom.table, r), |a| usize::try_from(a).unwrap_or(usize::MAX).min(last_row))
|
||||
} else {
|
||||
r
|
||||
};
|
||||
((anchor, r2), (0, last_col))
|
||||
}
|
||||
Spot::Cell(r, c) => {
|
||||
let (g0, g1) = table::grid_cols(t, r, c);
|
||||
match to {
|
||||
@@ -394,6 +440,19 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
table::select_range(s, story, &geom.table, rows, cols)
|
||||
}
|
||||
|
||||
/// The row a Shift+click on row `clicked` of table `tp` extends from: the end of the selected
|
||||
/// rows farther from it, or the caret's row; the clicked row itself when nothing of the table is
|
||||
/// selected. The app reads it at the press and sends it as `anchorRow` while the button is held.
|
||||
pub fn anchor_row(s: &Session, tp: &Path, clicked: usize) -> usize {
|
||||
if let Some(range) = s.cell_range().filter(|c| &c.table == tp) {
|
||||
return if clicked >= range.rows.0 { range.rows.0 } else { range.rows.1 };
|
||||
}
|
||||
match s.sel.anchor.path.cell() {
|
||||
Some((t, row, _)) if &t == tp => row,
|
||||
_ => clicked,
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell to insert a column beside at grid line `line`: one starting there (the column goes
|
||||
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
|
||||
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
|
||||
@@ -761,6 +820,103 @@ mod tests {
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
|
||||
}
|
||||
|
||||
/// The page margin beside a table is a row selection area too, as far as the selection bar
|
||||
/// reaches (Word): left of a left-to-right table, right of a right-to-left one.
|
||||
#[test]
|
||||
fn the_margin_beside_a_row_selects_it() {
|
||||
let mut s = table_session(3, 3);
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
let body_x = s.layout().pages[0].body.x;
|
||||
assert!(r1.x - 40.0 > 0.0 && r1.x - body_x < 24.0);
|
||||
// Far past the 24pt of the plain row area, still in the margin: the row.
|
||||
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
assert_eq!(spot_at(&mut s, body_x - 35.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
// The right margin is not a row bar of a left-to-right table.
|
||||
let right = s.layout().pages[0].body.right();
|
||||
assert_eq!(spot_at(&mut s, right + 20.0, mid_y(r1)), None);
|
||||
// A right-to-left table has its bars on the right.
|
||||
let tp = table_path(&s);
|
||||
s.doc.table_mut(StoryRef::Body, &tp).unwrap().props.bidi_visual = true;
|
||||
s.touch();
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
let (page_w, right) = (s.layout().pages[0].w, s.layout().pages[0].body.right());
|
||||
assert_eq!(spot_at(&mut s, right + 30.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
assert_eq!(spot_at(&mut s, page_w - 5.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), None);
|
||||
// Shift+click in the bar extends the selected rows from the anchor row.
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r1)})).unwrap();
|
||||
let r2 = cell_rect(&mut s, 2, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r2), "extend": true})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
|
||||
let r0 = cell_rect(&mut s, 0, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r0), "extend": true})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the far edge of the old rows stays");
|
||||
}
|
||||
|
||||
/// `anchorRow` fixes the row a Shift+click extends from, whatever is selected (a drag after
|
||||
/// an upward Shift+click keeps its rows); out of range it's the last row.
|
||||
#[test]
|
||||
fn an_anchor_row_param_fixes_the_extend_anchor() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (r0, r2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 0));
|
||||
let x = r0.x - 10.0;
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r2)})).unwrap();
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)));
|
||||
// Without it, the next frame of the drag would re-read the anchor as row 0.
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "toX": x, "toY": mid_y(r0) + 1.0, "extend": true, "anchorRow": 2}))
|
||||
.unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the press's anchor stays");
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 99999999999u64})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "clamped to the last row");
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 1})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 1)));
|
||||
}
|
||||
|
||||
/// The margin is the row bar only where the table is what's beside it: not at a column-2
|
||||
/// table's height beside column 1's lines (the selection bar's lines), nor beside text
|
||||
/// wrapped round a floating table.
|
||||
#[test]
|
||||
fn the_row_bar_needs_the_table_beside_the_margin() {
|
||||
// A two-column section: 36 short lines fill column 1 and start column 2, then a table.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
let lines: Vec<String> = (0..36).map(|i| format!("Line {i}")).collect();
|
||||
s.run("text.insert", &json!({"text": format!("{}\r", lines.join("\r"))})).unwrap();
|
||||
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let r0 = cell_rect(&mut s, 0, 0);
|
||||
let body = s.layout().pages[0].body;
|
||||
assert!(r0.x > body.x + body.w / 2.0, "the table is in column 2: {r0:?}");
|
||||
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "column 1's line is the selection bar's");
|
||||
// A floating table with text wrapped to its left.
|
||||
let mut d = Document::from_text(&"Words that wrap round the floating table beside them. ".repeat(40));
|
||||
let mut t = Table::new(3, 1, 150.0);
|
||||
t.props.float = Some(wordcraft_doc::props::TableFloat {
|
||||
h_rel: wordcraft_doc::para::Anchor::Margin,
|
||||
v_rel: wordcraft_doc::para::Anchor::Paragraph,
|
||||
x: 300.0,
|
||||
overlap: false,
|
||||
..Default::default()
|
||||
});
|
||||
d.insert_block(StoryRef::Body, &Path(vec![0]), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let mut s = Session::new(d);
|
||||
let r0 = cell_rect(&mut s, 0, 0);
|
||||
assert!(r0.x > 300.0, "the table floats at the right: {r0:?}");
|
||||
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "the text wrapped beside it has the line bar");
|
||||
}
|
||||
|
||||
/// A paragraph right above the table (no space after) doesn't hide the first row's bar.
|
||||
#[test]
|
||||
fn a_paragraph_flush_above_the_table_keeps_the_row_bar() {
|
||||
let mut d = Document::from_text("Above");
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(0.0);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(Table::new(2, 2, 400.0))).unwrap();
|
||||
let mut s = Session::new(d);
|
||||
let r0 = cell_rect(&mut s, 0, 0);
|
||||
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), Some(Spot::Row(0)));
|
||||
assert_eq!(spot_at(&mut s, 10.0, r0.y + 0.3), Some(Spot::Row(0)), "even at the very top of the row");
|
||||
}
|
||||
|
||||
/// The insert buttons add a row or column at their border, one undo step, and select it (as
|
||||
/// Word does); typing then goes into its first cell.
|
||||
#[test]
|
||||
|
||||
@@ -460,12 +460,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
|
||||
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc)
|
||||
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks"#),
|
||||
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks; the removals are refused while the document tracks changes"#),
|
||||
CommandSpec::new("file.protect", "Protect Document", "File › Info", |s, v| {
|
||||
let mode = p::str(v, "mode").unwrap_or("readOnly");
|
||||
s.run("review.restrict", &json!({"mode": mode}))
|
||||
})
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"}"#),
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
|
||||
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
|
||||
if let Some(label) = p::str(v, "save") {
|
||||
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
|
||||
@@ -475,8 +475,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
if let Some(i) = p::u64(v, "restore") {
|
||||
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
s.checkpoint("Restore Version");
|
||||
// A snapshot's own protection is stale: it was taken at some earlier moment, and
|
||||
// restoring it must not bring a protection back that the live document doesn't
|
||||
// have (#1327 follow-up).
|
||||
let protection = s.doc.settings.protection.clone();
|
||||
let protect_hash = s.doc.settings.protect_hash.clone();
|
||||
let protect_formatting = s.doc.settings.protect_formatting;
|
||||
s.doc = d;
|
||||
s.doc.settings.protection = protection;
|
||||
s.doc.settings.protect_hash = protect_hash;
|
||||
s.doc.settings.protect_formatting = protect_formatting;
|
||||
s.touch();
|
||||
s.clamp_selection();
|
||||
}
|
||||
@@ -526,7 +539,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("file.share", "Share", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
s.push_ui_request(json!({"open": "saveAs"}));
|
||||
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
|
||||
})
|
||||
.pure(),
|
||||
@@ -1276,6 +1289,9 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// The result is a new, untitled document: saving it never overwrites either file.
|
||||
s.path = None;
|
||||
s.file_name = None;
|
||||
// Version snapshots belong to the document they were taken from, including when
|
||||
// Compare creates an untitled result from the current document.
|
||||
s.clear_versions();
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
|
||||
@@ -1366,6 +1382,67 @@ fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
|
||||
ops
|
||||
}
|
||||
|
||||
/// How short an interior match can be, inside a run of edits, before it's folded into the
|
||||
/// surrounding delete/insert instead of staying its own little island of "same" text. Two short
|
||||
/// strings that happen to share a character in a different order (common with digits in dates,
|
||||
/// IDs, phone numbers) otherwise come out of `lcs_ops` as a true shortest edit script, but render
|
||||
/// as a "ransom note" of alternating single-character deletions and insertions where a reader
|
||||
/// (and Word) would expect one clean replacement.
|
||||
const NOISE_MATCH_LEN: usize = 1;
|
||||
|
||||
/// Folds single-token matches that sit strictly inside a run of edits into the surrounding
|
||||
/// delete/insert, so a character-level diff doesn't fragment a replaced run. A matched run that
|
||||
/// opens or closes the whole sequence is the genuine common prefix/suffix `lcs_ops` already
|
||||
/// stripped before running its table, so it's left untouched regardless of length; only a short
|
||||
/// match produced by the table's own backtracking, which can only ever be interior, is a
|
||||
/// candidate.
|
||||
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Keep,
|
||||
Fold,
|
||||
}
|
||||
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < ops.len() {
|
||||
let op = ops[k];
|
||||
let len = ops[k..].iter().take_while(|o| **o == op).count();
|
||||
let kind = if op == Op::Same && k != 0 && k + len != ops.len() && len <= NOISE_MATCH_LEN { Kind::Fold } else { Kind::Keep };
|
||||
runs.push((op, len, kind));
|
||||
k += len;
|
||||
}
|
||||
if !runs.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
|
||||
return ops.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(ops.len());
|
||||
let mut i = 0;
|
||||
while i < runs.len() {
|
||||
if runs[i].0 == Op::Same && runs[i].2 == Kind::Keep {
|
||||
out.extend(std::iter::repeat_n(Op::Same, runs[i].1));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < runs.len() && !(runs[i].0 == Op::Same && runs[i].2 == Kind::Keep) {
|
||||
i += 1;
|
||||
}
|
||||
let region = &runs[start..i];
|
||||
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
|
||||
// Flatten the region: every original character first (in its own order), then every
|
||||
// revised character (in its own order) — one clean delete, one clean insert.
|
||||
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
|
||||
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
|
||||
out.extend(std::iter::repeat_n(Op::Del, dels));
|
||||
out.extend(std::iter::repeat_n(Op::Ins, inss));
|
||||
} else {
|
||||
for (op, len, _) in region {
|
||||
out.extend(std::iter::repeat_n(*op, *len));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Words with the spaces after them ("The ", "cat "), or single characters.
|
||||
fn tokens(s: &str, characters: bool) -> Vec<String> {
|
||||
if characters {
|
||||
@@ -1390,6 +1467,10 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOpt
|
||||
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
|
||||
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
|
||||
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
|
||||
// Only at character granularity: a matched whole word almost never happens by coincidence,
|
||||
// but a matched single character very often does (digits, punctuation), so only there does
|
||||
// a short interior match need folding back into its surrounding edit.
|
||||
let ops = if chars { collapse_noise_matches(&ops) } else { ops };
|
||||
let mut p = Paragraph::new();
|
||||
p.props = b.props.clone();
|
||||
let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default();
|
||||
@@ -1424,6 +1505,13 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
|
||||
fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let remove: Vec<String> =
|
||||
v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
// The removals aren't tracked: they delete straight from the document, so on a document that
|
||||
// tracks changes the text would go with no revision to show for it, and the revision trail
|
||||
// would be broken behind the user's back. Refuse the whole removal instead of quietly
|
||||
// untracking it. Reading the document (no `remove`) is always allowed.
|
||||
if !remove.is_empty() && s.doc.settings.track_changes {
|
||||
return Err(CmdError::Disabled("the document tracks changes: stop tracking before removing content".into()));
|
||||
}
|
||||
let hidden = hidden_text(&s.doc);
|
||||
let report = json!({
|
||||
"comments": s.doc.comments.len(),
|
||||
@@ -1856,7 +1944,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null));
|
||||
if !asked {
|
||||
s.ui_requests.push(json!({"open": "customizeKeyboard"}));
|
||||
s.push_ui_request(json!({"open": "customizeKeyboard"}));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
@@ -1865,7 +1953,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// with no query, the Search box (`ui_requests` → the UI's command search).
|
||||
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(query) = p::str(v, "query") else {
|
||||
s.ui_requests.push(json!({"open": "commands"}));
|
||||
s.push_ui_request(json!({"open": "commands"}));
|
||||
return Ok(json!({"matches": []}));
|
||||
};
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||
@@ -1961,7 +2049,7 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
|
||||
out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null));
|
||||
out.insert("qat".into(), json!(l.qat()));
|
||||
if !asked {
|
||||
s.ui_requests.push(json!({"open": "customizeRibbon"}));
|
||||
s.push_ui_request(json!({"open": "customizeRibbon"}));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
@@ -1999,6 +2087,18 @@ mod tests {
|
||||
let p = s.doc.body[0].as_para().unwrap();
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "u");
|
||||
// Two dates that happen to share digits in a different order ("01.09.2026" vs
|
||||
// "19.10.2026") must come out as one clean replacement, not a scrambled chain of
|
||||
// single-character deletions and insertions wherever a digit coincidentally recurs.
|
||||
let mut s = Session::new(Document::from_text("zawarta w dniu 01.09.2026 pomiędzy:"));
|
||||
s.run("review.compare", &json!({"text": "zawarta w dniu 19.10.2026 pomiędzy:", "level": "character"})).unwrap();
|
||||
let p = s.doc.body[0].as_para().unwrap();
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "01.09");
|
||||
assert_eq!(inserted, "19.10");
|
||||
assert_eq!(p.run_ranges().filter(|(_, c)| c.del.is_some()).count(), 1, "one clean deleted run, not several scattered ones");
|
||||
assert_eq!(p.run_ranges().filter(|(_, c)| c.ins.is_some()).count(), 1, "one clean inserted run, not several scattered ones");
|
||||
// Formatting differences become formatting changes.
|
||||
let mut s = Session::new(Document::from_text("Same words."));
|
||||
let mut rev = Document::from_text("Same words.");
|
||||
@@ -2011,6 +2111,33 @@ mod tests {
|
||||
assert!(s.doc.revisions.iter().any(|x| x.kind == RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
|
||||
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
|
||||
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
|
||||
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
|
||||
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
|
||||
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
|
||||
// A plain single-character replacement flanked by a real common prefix and suffix has no
|
||||
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
|
||||
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
|
||||
assert_eq!(collapse_noise_matches(&ops), ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
|
||||
// `collapse_noise_matches` only runs for character-level compares (wired up in
|
||||
// `word_diff`): a single matched *word* between two edits is a real, meaningful match
|
||||
// (unlike a single matched character), and must never be folded into a replacement.
|
||||
let mut s = Session::new(Document::from_text("the cat sat"));
|
||||
s.run("review.compare", &json!({"text": "a cat ran"})).unwrap();
|
||||
let p = s.doc.body[0].as_para().unwrap();
|
||||
assert!(
|
||||
p.run_ranges().any(|(r, c)| c.del.is_none() && c.ins.is_none() && p.text[r].trim() == "cat"),
|
||||
"the unchanged word must survive as plain text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_record_and_run() {
|
||||
let mut s = Session::new(Document::new());
|
||||
@@ -2038,7 +2165,11 @@ mod tests {
|
||||
let mut s = Session::new(Document::from_text("locked"));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
|
||||
s.run("review.restrict", &json!({"mode": "none"})).unwrap();
|
||||
// The "none" mode is the shorthand for stopping the protection, and it doesn't stop a
|
||||
// protected document: only the explicit stop does (#1327 follow-up).
|
||||
assert!(s.run("review.restrict", &json!({"mode": "none"})).is_err());
|
||||
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "x"})).is_ok());
|
||||
}
|
||||
|
||||
@@ -2068,4 +2199,40 @@ mod tests {
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
|
||||
}
|
||||
|
||||
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
|
||||
/// document that tracks changes the text would go with no revision to show for it, and the
|
||||
/// revision trail would be broken behind the user's back. The removal is refused instead,
|
||||
/// and the document is left as it was.
|
||||
#[test]
|
||||
fn inspect_removals_are_refused_while_the_document_tracks_changes() {
|
||||
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
|
||||
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
|
||||
s.run("format.hidden", &json!({"value": true})).unwrap();
|
||||
// Tracking without Restrict Editing isolates the Inspector guard from the protected
|
||||
// document check added separately in #3285.
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
assert!(s.doc.settings.track_changes, "the document doesn't track changes");
|
||||
let before = s.doc.clone();
|
||||
let err = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap_err();
|
||||
assert!(matches!(err, CmdError::Disabled(_)), "{err}");
|
||||
assert_eq!(s.doc, before, "the document changed despite the refusal");
|
||||
// Reading the document stays allowed.
|
||||
let found = s.run("file.inspect", &json!({})).unwrap();
|
||||
assert_eq!(found["found"]["hiddenText"], true, "{found}");
|
||||
}
|
||||
|
||||
/// A document that doesn't track changes is inspected as before: the hidden text goes.
|
||||
#[test]
|
||||
fn inspect_removals_work_when_the_document_does_not_track_changes() {
|
||||
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
|
||||
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
|
||||
s.run("format.hidden", &json!({"value": true})).unwrap();
|
||||
assert!(!s.doc.settings.track_changes);
|
||||
let out = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap();
|
||||
assert_eq!(out["residual"], json!([]), "{out}");
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
|
||||
assert!(text.contains("Visible text."), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub const MAX_TEMPLATES: usize = 500;
|
||||
/// Most category folders looked into.
|
||||
pub const MAX_CATEGORIES: usize = 64;
|
||||
/// Most directory entries looked at in one folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
/// Longest folder path taken, in bytes.
|
||||
const MAX_DIR: usize = 32 * 1024;
|
||||
@@ -186,6 +187,7 @@ fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::P
|
||||
/// The name a template shows: its file name without the template extension, every one of them
|
||||
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
|
||||
/// nothing would be left.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn label_of(name: &str) -> String {
|
||||
let mut label = name;
|
||||
while is_template_name(label) {
|
||||
@@ -355,6 +357,7 @@ mod tests {
|
||||
/// .dotx and .dotm templates show their names the same way, without the extension; two that
|
||||
/// would look alike in one category show their file names.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
|
||||
let d = scratch("labels");
|
||||
template(&d.join("Service contract.dotm"), "a", "");
|
||||
|
||||
@@ -91,12 +91,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// light and dark, as the manual switch did.
|
||||
CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") {
|
||||
None => {
|
||||
s.ui_requests.push(json!({"toggle": "interfaceTheme"}));
|
||||
s.push_ui_request(json!({"toggle": "interfaceTheme"}));
|
||||
Ok(json!({"value": "toggle"}))
|
||||
}
|
||||
Some(Value::String(name)) => match name.as_str() {
|
||||
"light" | "dark" | "system" => {
|
||||
s.ui_requests.push(json!({"interfaceTheme": name}));
|
||||
s.push_ui_request(json!({"interfaceTheme": name}));
|
||||
Ok(json!({"value": name}))
|
||||
}
|
||||
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
|
||||
@@ -121,12 +121,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "newWindow"}));
|
||||
s.push_ui_request(json!({"open": "newWindow"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"toggle": "split"}));
|
||||
s.push_ui_request(json!({"toggle": "split"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Alt+S")
|
||||
@@ -141,8 +141,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
|
||||
.pure(),
|
||||
// Word › File › Options › Advanced › Numeral: how digits look on screen (display
|
||||
// only; the document text keeps the typed codepoints).
|
||||
// Word › File › Options › Advanced › Numeral: how digits look on screen, in print, PDFs
|
||||
// and page images (display only; the document text keeps the typed codepoints).
|
||||
CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") {
|
||||
None => Ok(json!({"value": s.view.numeral})),
|
||||
Some(Value::String(name)) => {
|
||||
@@ -256,7 +256,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.view.multi_page = false;
|
||||
}
|
||||
None => {
|
||||
s.ui_requests.push(json!({"open": "zoom"}));
|
||||
s.push_ui_request(json!({"open": "zoom"}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct ExportOptions {
|
||||
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
|
||||
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
|
||||
pub word2003: bool,
|
||||
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
|
||||
pub numeral: wordcraft_layout::NumeralMode,
|
||||
}
|
||||
|
||||
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
||||
|
||||
@@ -31,7 +31,12 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
let pdf = wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, ..Default::default() };
|
||||
let pdf = wordcraft_pdf::PdfOptions {
|
||||
include_markup: opts.markup,
|
||||
comments: opts.markup && opts.comments,
|
||||
numeral: opts.numeral,
|
||||
..Default::default()
|
||||
};
|
||||
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
|
||||
}
|
||||
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
|
||||
@@ -41,7 +46,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
|
||||
/// markup area with the comments in balloons (page images always show tracked changes).
|
||||
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, ..Default::default() };
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
|
||||
|
||||
@@ -419,6 +419,10 @@ pub struct Session {
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
/// What Restrict Editing's outright refusals depend on, for greying out commands
|
||||
/// ([`Session::why_disabled`]): computed once per document, revision and author instead of
|
||||
/// for every button on every frame.
|
||||
protect_facts: std::sync::Mutex<Option<((u64, u64, String), crate::cmd::protect::Facts)>>,
|
||||
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
|
||||
/// mail merge, recover), never on an edit.
|
||||
document_id: u64,
|
||||
@@ -637,12 +641,23 @@ fn doc_bib_style(doc: &Document) -> String {
|
||||
doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string()
|
||||
}
|
||||
|
||||
/// Most pending front-end requests a session keeps.
|
||||
pub const MAX_UI_REQUESTS: usize = 64;
|
||||
/// Maximum undo depth.
|
||||
const MAX_UNDO: usize = 500;
|
||||
/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further.
|
||||
pub const UNDO_LIST_MAX: usize = 100;
|
||||
|
||||
impl Session {
|
||||
/// Queue a request for the front end. A headless session never drains the queue, so only
|
||||
/// the newest [`MAX_UI_REQUESTS`] are kept.
|
||||
pub fn push_ui_request(&mut self, req: Value) {
|
||||
if self.ui_requests.len() >= MAX_UI_REQUESTS {
|
||||
self.ui_requests.remove(0);
|
||||
}
|
||||
self.ui_requests.push(req);
|
||||
}
|
||||
|
||||
/// File name of the persisted custom dictionary inside the settings folder.
|
||||
pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
|
||||
|
||||
@@ -691,6 +706,7 @@ impl Session {
|
||||
join_next: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
protect_facts: std::sync::Mutex::new(None),
|
||||
document_id: 1,
|
||||
cache: LayoutCache::new(),
|
||||
layout: None,
|
||||
@@ -962,6 +978,7 @@ impl Session {
|
||||
let opts = LayoutOptions {
|
||||
view: ViewMode::Print,
|
||||
comment_balloons: markup && self.prefs.markup.comments,
|
||||
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
|
||||
filename: Some(Arc::from(fn_name.as_str())),
|
||||
filepath: Some(Arc::from(fn_path.as_str())),
|
||||
..Default::default()
|
||||
@@ -1158,6 +1175,12 @@ impl Session {
|
||||
self.typing_open = false;
|
||||
}
|
||||
|
||||
/// The version snapshots belong to the document they were taken from: keeping the old ones
|
||||
/// would let a restore put the previous document's content into this one (#1327 follow-up).
|
||||
pub fn clear_versions(&mut self) {
|
||||
self.versions.clear();
|
||||
}
|
||||
|
||||
/// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the
|
||||
/// system's.
|
||||
pub fn region(&self) -> wordcraft_geom::Region {
|
||||
@@ -1216,6 +1239,7 @@ impl Session {
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.reset_history();
|
||||
self.clear_versions();
|
||||
self.touch();
|
||||
self.dirty = false;
|
||||
self.relayout();
|
||||
@@ -1272,6 +1296,7 @@ impl Session {
|
||||
join_next,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
protect_facts: _,
|
||||
cache: _,
|
||||
layout: _,
|
||||
// The word being typed, like `column`: valid only while `sel` is where typing left it.
|
||||
@@ -1375,7 +1400,21 @@ impl Session {
|
||||
/// Restrict Editing refuses outright. `None` when it can run (or doesn't exist).
|
||||
pub fn why_disabled(&self, id: &str) -> Option<&'static str> {
|
||||
let spec = self.registry.get(id)?;
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id) } else { None })
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
}
|
||||
|
||||
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
|
||||
fn protect_facts(&self) -> crate::cmd::protect::Facts {
|
||||
let key = (self.document_id, self.rev, self.author.clone());
|
||||
let mut cache = self.protect_facts.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((k, f)) = cache.as_ref()
|
||||
&& *k == key
|
||||
{
|
||||
return *f;
|
||||
}
|
||||
let f = crate::cmd::protect::Facts::of(self);
|
||||
*cache = Some((key, f));
|
||||
f
|
||||
}
|
||||
|
||||
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
|
||||
@@ -1435,6 +1474,22 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// These mutating commands can replace the open document and discard its saved versions.
|
||||
// Keep the versions until the command and the protected-region post-check have both
|
||||
// succeeded; rejected commands must leave the old document and its snapshots together.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
|
||||
let versions_before = replaces_document.then(|| self.versions.clone());
|
||||
let identity_before = replaces_document.then(|| {
|
||||
(
|
||||
self.path.clone(),
|
||||
self.file_name.clone(),
|
||||
self.document_id,
|
||||
self.password.clone(),
|
||||
self.dirty,
|
||||
self.bib_style.clone(),
|
||||
self.merge.preview,
|
||||
)
|
||||
});
|
||||
let typing = spec.id == "text.insert" || spec.id == "equation.type";
|
||||
if spec.mutates && !typing {
|
||||
self.typing_open = false;
|
||||
@@ -1516,6 +1571,18 @@ impl Session {
|
||||
Err(e) => {
|
||||
self.autocap = None;
|
||||
self.prefs.default_language = default_language_before;
|
||||
if let Some(versions) = versions_before {
|
||||
self.versions = versions;
|
||||
}
|
||||
if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before {
|
||||
self.path = path;
|
||||
self.file_name = file_name;
|
||||
self.document_id = document_id;
|
||||
self.password = password;
|
||||
self.dirty = dirty;
|
||||
self.bib_style = bib_style;
|
||||
self.merge.preview = merge_preview;
|
||||
}
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
self.doc = d;
|
||||
self.sel = s;
|
||||
|
||||
@@ -249,6 +249,7 @@ pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -
|
||||
|
||||
/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags
|
||||
/// without a two- or three-letter language, such as Word's `x-none`.
|
||||
#[cfg(any(unix, windows, test))]
|
||||
fn primary_tag(tag: &str) -> Option<String> {
|
||||
let t = plain_tag(tag)?;
|
||||
t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase)
|
||||
@@ -256,7 +257,7 @@ fn primary_tag(tag: &str) -> Option<String> {
|
||||
|
||||
/// The language to ask the speech program for, or `None` to keep its default voice: only when
|
||||
/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already.
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
#[cfg(any(unix, test))]
|
||||
fn voice_language(lang: Option<&str>, default: &str) -> Option<String> {
|
||||
lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default))
|
||||
}
|
||||
|
||||
@@ -3349,6 +3349,8 @@ fn hostile_params_never_panic() {
|
||||
json!({}),
|
||||
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
|
||||
json!({"value": -1e308, "rows": 1e9}),
|
||||
json!({"page": 0, "x": 10, "y": 100, "extend": "x", "toX": null, "toY": 1e30, "reach": "r"}),
|
||||
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
|
||||
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
|
||||
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
|
||||
];
|
||||
@@ -3423,6 +3425,106 @@ fn text_box_insert_puts_the_caret_inside() {
|
||||
assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_commands_on_a_selected_text_box_format_all_its_text() {
|
||||
// Word: with a text box selected as an object (its frame, not text in it), the Font group
|
||||
// formats all the box's text. Select All doesn't reach a floating box's text, so this is how
|
||||
// its font is changed (and the only way when the box sits behind text, where a click goes to
|
||||
// the text over it).
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Before "}));
|
||||
let id = run(&mut s, "insert.textBox", json!({"text": "Box words"}))["story"].as_u64().unwrap() as u32;
|
||||
let boxed = StoryRef::Part(id);
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "second"}));
|
||||
run(&mut s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())}));
|
||||
run(&mut s, "format.font", json!({"name": "TH Sarabun New"}));
|
||||
run(&mut s, "format.size", json!({"size": 18}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
let paths = s.doc.para_paths(boxed);
|
||||
assert_eq!(paths.len(), 2);
|
||||
for path in &paths {
|
||||
for (_, c) in s.doc.para(boxed, path).unwrap().run_ranges() {
|
||||
assert_eq!(c.font.as_deref(), Some("TH Sarabun New"));
|
||||
assert_eq!(c.font_cs.as_deref(), Some("TH Sarabun New"));
|
||||
assert_eq!(c.size, Some(18.0));
|
||||
assert_eq!(c.bold, Some(true));
|
||||
}
|
||||
}
|
||||
// The body text around the box is left as it was, and the box stays selected.
|
||||
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
assert!(body.run_ranges().all(|(_, c)| c.font.is_none() && c.bold.is_none()));
|
||||
assert!(crate::cmd::objects::object_selection(&s).is_some());
|
||||
// Bold reads the box's text, so pressing it again takes it off.
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(false)));
|
||||
// One undo step per command.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_and_clearing_commands_on_a_selected_text_box_work_on_its_text() {
|
||||
// Word, with a text box selected as an object: Change Case, Clear All Formatting and Reset
|
||||
// Character Formatting work on the box's text and leave the body text around it alone.
|
||||
let boxed = |s: &mut Session| {
|
||||
run(s, "text.insert", json!({"text": "Before "}));
|
||||
run(s, "format.bold", json!({"value": true}));
|
||||
run(s, "text.insert", json!({"text": "body"}));
|
||||
run(s, "caret.set", json!({"pos": Pos::body(0, "Before ".len())}));
|
||||
let id = run(s, "insert.textBox", json!({"text": "box text"}))["story"].as_u64().unwrap() as u32;
|
||||
run(s, "select.all", json!({}));
|
||||
run(s, "format.italic", json!({"value": true}));
|
||||
run(s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())}));
|
||||
StoryRef::Part(id)
|
||||
};
|
||||
let body_bold = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().run_ranges().any(|(_, c)| c.bold == Some(true));
|
||||
|
||||
let mut cased = s();
|
||||
let id = boxed(&mut cased);
|
||||
run(&mut cased, "format.changeCase", json!({"mode": "upper"}));
|
||||
assert_eq!(cased.doc.plain_text(id), "BOX TEXT");
|
||||
assert_eq!(cased.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).map(|p| p.text.as_str()), Some("Before \u{FFFC}body"));
|
||||
assert!(crate::cmd::objects::object_selection(&cased).is_some(), "the box stays selected");
|
||||
|
||||
for cmd in ["format.clear", "format.resetChar"] {
|
||||
let mut cleared = s();
|
||||
let id = boxed(&mut cleared);
|
||||
run(&mut cleared, cmd, json!({}));
|
||||
let p = cleared.doc.para(id, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
assert!(p.run_ranges().all(|(_, c)| c.italic.is_none()), "{cmd} clears the box's text");
|
||||
assert!(body_bold(&cleared), "{cmd} leaves the body text's formatting");
|
||||
assert!(crate::cmd::objects::object_selection(&cleared).is_some(), "the box stays selected");
|
||||
}
|
||||
|
||||
// An empty box: Bold reads (and sets) its paragraph mark, not the anchor's run around it.
|
||||
let mut empty = s();
|
||||
run(&mut empty, "format.bold", json!({"value": true}));
|
||||
run(&mut empty, "text.insert", json!({"text": "Bold body "}));
|
||||
let id = run(&mut empty, "insert.textBox", json!({}))["story"].as_u64().unwrap() as u32;
|
||||
let anchor = empty.doc.text_box_anchor(id).unwrap();
|
||||
run(&mut empty, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor}));
|
||||
run(&mut empty, "format.bold", json!({}));
|
||||
let mark = &empty.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap().mark;
|
||||
assert_eq!(mark.bold, Some(true), "the empty box wasn't bold, so Bold turns it on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_text_box_shows_its_own_paragraph_style() {
|
||||
// Word, with a text box selected as an object, shows the style of the box's text, not of the
|
||||
// body paragraph the box is anchored in, as it does for its font.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Body "}));
|
||||
let id = run(&mut s, "insert.textBox", json!({"text": "Title"}))["story"].as_u64().unwrap() as u32;
|
||||
run(&mut s, "para.style", json!({"style": "Heading 1"}));
|
||||
let inside = run(&mut s, "format.state", json!({}));
|
||||
let anchor = s.doc.text_box_anchor(id).unwrap();
|
||||
run(&mut s, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor}));
|
||||
let selected = run(&mut s, "format.state", json!({}));
|
||||
assert_ne!(inside["style"], json!("Normal"), "the box's paragraph has a style of its own: {inside}");
|
||||
assert_eq!((&selected["style"], &selected["styleName"]), (&inside["style"], &inside["styleName"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_after_the_largest_ids_take_free_ones() {
|
||||
// A file can carry any ids, 0 and u32::MAX included: comments, notes, boxes and lists the
|
||||
@@ -5036,6 +5138,45 @@ fn numeral_mode_sets_gets_and_rejects_junk() {
|
||||
assert_eq!(text(&s), "Year 2024");
|
||||
}
|
||||
|
||||
/// The Numeral setting also applies to print and PDF output, not just the screen layout.
|
||||
#[test]
|
||||
fn export_layout_follows_the_numeral_setting() {
|
||||
use wordcraft_layout::Placed;
|
||||
fn glyph_ids(s: &Session) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
for page in &s.export_layout().pages {
|
||||
for it in &page.items {
|
||||
if let Placed::Lines { para, .. } = it {
|
||||
out.extend(para.glyphs.iter().map(|g| g.gid));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let mut s = s();
|
||||
s.doc = wordcraft_doc::Document::from_text("Year 2024");
|
||||
let western = glyph_ids(&s);
|
||||
assert!(!western.is_empty());
|
||||
run(&mut s, "view.numeral", json!({"value": "hindi"}));
|
||||
assert_ne!(glyph_ids(&s), western, "Hindi digits reach the printed layout");
|
||||
}
|
||||
|
||||
/// File › Export PDF, Save As PDF and page images take the Numeral setting too.
|
||||
#[test]
|
||||
fn pdf_and_png_copies_follow_the_numeral_setting() {
|
||||
let mut s = s();
|
||||
s.doc = wordcraft_doc::Document::from_text("Year 2024");
|
||||
// A fixed creation date, so the PDF bytes depend only on the layout.
|
||||
s.doc.core.created = "2024-01-01T00:00:00Z".into();
|
||||
let copy = |s: &Session, name: &str| crate::io::save_bytes_opts(name, &s.doc, &cmd::file::export_options(s, &json!({}))).unwrap();
|
||||
let (pdf, png) = (copy(&s, "x.pdf"), copy(&s, "x.png"));
|
||||
assert_eq!(copy(&s, "x.pdf"), pdf);
|
||||
run(&mut s, "view.numeral", json!({"value": "hindi"}));
|
||||
assert_eq!(cmd::file::export_options(&s, &json!({})).numeral, wordcraft_layout::NumeralMode::Hindi);
|
||||
assert_ne!(copy(&s, "x.pdf"), pdf, "Hindi digits reach the PDF");
|
||||
assert_ne!(copy(&s, "x.png"), png, "Hindi digits reach the page image");
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
|
||||
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
|
||||
/// short of the 10%–500% limits.
|
||||
@@ -6311,6 +6452,35 @@ fn convert_text_to_table_refuses_to_drop_content() {
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_text_to_table_keeps_inline_content() {
|
||||
// Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links
|
||||
// and tracked deletions.
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"}));
|
||||
{
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.format(0, 4, &|c| c.bold = Some(true)).unwrap();
|
||||
p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap();
|
||||
p.format(11, 14, &|c| c.del = Some(1)).unwrap();
|
||||
let img =
|
||||
InlineObject::Image { media: "pic".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
p.insert_object(5, img, &Default::default()).unwrap();
|
||||
}
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "table.fromText", json!({}));
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||||
let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph");
|
||||
assert_eq!(cell(0).text, "Name");
|
||||
assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold");
|
||||
assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture");
|
||||
assert_eq!(cell(2).text, "Link");
|
||||
assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link");
|
||||
assert_eq!(cell(3).text, "Old");
|
||||
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_columns_deletes_every_selected_column() {
|
||||
// Issue #431: only the selection's focus column went.
|
||||
@@ -8300,6 +8470,122 @@ fn rename_unsaved_document_names_the_file_not_the_document() {
|
||||
assert!(s.file_name.is_none() && s.path.is_none());
|
||||
}
|
||||
|
||||
/// A version snapshot belongs to the document it was taken from: opening another document (or
|
||||
/// starting a new one) starts a new version history, so a restore can't put the previous
|
||||
/// document's content into the open one (#1327 follow-up).
|
||||
#[test]
|
||||
fn a_version_snapshot_does_not_survive_a_document_switch() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("First document."));
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
assert_eq!(s.versions.len(), 1);
|
||||
|
||||
// A new document: the snapshot of the previous one is gone with it.
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
|
||||
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
|
||||
assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back");
|
||||
|
||||
// The same for an opened file.
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap();
|
||||
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
|
||||
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
|
||||
|
||||
// A snapshot of the open document still restores into it.
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " there"})).unwrap();
|
||||
s.run("file.versions", &json!({"restore": 0})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
|
||||
}
|
||||
|
||||
/// A comparison that opens the original replaces the open document, so the version snapshots
|
||||
/// of the document it replaces go with it: a restore must not put the previous document back
|
||||
/// over the comparison result (#1327 follow-up).
|
||||
#[test]
|
||||
fn a_version_snapshot_does_not_survive_a_comparison() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("original.docx");
|
||||
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
|
||||
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
assert_eq!(s.versions.len(), 1);
|
||||
s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap();
|
||||
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison");
|
||||
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Compare's new and revised results are untitled documents even without an `original` path.
|
||||
#[test]
|
||||
fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
|
||||
for show_in in ["new", "revised"] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap();
|
||||
assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison");
|
||||
assert!(s.run("file.versions", &json!({"restore": 0})).is_err());
|
||||
assert!(s.path.is_none() && s.file_name.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// A rejected comparison leaves the current document and its snapshots together.
|
||||
#[test]
|
||||
fn a_rejected_comparison_keeps_the_current_versions() {
|
||||
use wordcraft_doc::{CharProps, InlineObject};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked."));
|
||||
s.path = Some(std::path::PathBuf::from("original.docx"));
|
||||
s.file_name = Some("original.docx".into());
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
|
||||
assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot");
|
||||
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx")));
|
||||
assert_eq!(s.file_name.as_deref(), Some("original.docx"));
|
||||
}
|
||||
|
||||
/// A rejected mailing result must keep the source document's save target and versions.
|
||||
#[test]
|
||||
fn a_rejected_mailing_result_keeps_the_current_file_identity() {
|
||||
use wordcraft_doc::{CharProps, InlineObject};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source."));
|
||||
s.path = Some(std::path::PathBuf::from("source.docx"));
|
||||
s.file_name = Some("source.docx".into());
|
||||
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let before_id = s.document_id();
|
||||
let before_dirty = s.dirty;
|
||||
s.password = Some(crate::io::Password::new("synthetic file password"));
|
||||
s.bib_style = "Chicago".into();
|
||||
|
||||
assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.versions.len(), 1);
|
||||
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx")));
|
||||
assert_eq!(s.file_name.as_deref(), Some("source.docx"));
|
||||
assert_eq!(s.document_id(), before_id);
|
||||
assert_eq!(s.dirty, before_dirty);
|
||||
assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password"));
|
||||
assert_eq!(s.bib_style, "Chicago");
|
||||
}
|
||||
|
||||
/// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct,
|
||||
/// character style, paragraph style) in every story — body, header, footer, footnote, comment,
|
||||
/// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save.
|
||||
@@ -9213,3 +9499,40 @@ fn table_cell_margins_preserves_unpassed_sides() {
|
||||
let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap();
|
||||
assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_ui_requests_stay_bounded() {
|
||||
let mut s = s();
|
||||
let big = "x".repeat(1 << 20);
|
||||
for _ in 0..200 {
|
||||
run(&mut s, "edit.pasteSpecial", json!({"text": big}));
|
||||
run(&mut s, "edit.goto", json!({}));
|
||||
}
|
||||
assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len());
|
||||
let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum();
|
||||
assert!(size < 4 << 20, "queued {size} bytes");
|
||||
}
|
||||
|
||||
/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with
|
||||
/// page images (#3696).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_png_refuses_a_path_that_is_not_png() {
|
||||
let dir = scratch_dir("export-png-extension");
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
for name in ["notes.txt", "config", "report.docx", "page.png.txt"] {
|
||||
let target = dir.join(name);
|
||||
std::fs::write(&target, b"precious user notes").unwrap();
|
||||
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed");
|
||||
}
|
||||
// A .png path still works, whatever its case.
|
||||
for name in ["page.png", "PAGE.PNG"] {
|
||||
let target = dir.join(name);
|
||||
s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap();
|
||||
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user