Compare commits

..
Author SHA1 Message Date
Brandon Thomas bce26c6f6a Merge main into PR #850 2026-10-12 04:31:06 -04:00
Brandon ThomasandClaude Opus 5.5 43fbb06567 Tests: linear-time checks measure CPU time, not wall clock under load
The five tests that assert linear layout/export/hyphenation by comparing
the cost of an n-sized and a larger input (layout: unequal formats, hidden
separators with auto hyphenation, hidden float anchors; pdf: fragmented
hidden headings; proof: hyphenating a long word) timed both with wall
clock, fastest of three. On a loaded machine (CI, shared dev boxes, the
test harness running other tests in parallel) a preempted big run, timer
resolution on the small run, or a tight 4x/8.0 limit (hyphenation measured
~7.2 when linear) made them fail now and then.

Each now uses a shared cost_ratio helper (test-only, no production change):
- CPU time of the test thread from /proc/thread-self/schedstat on Linux, so
  time spent waiting for a CPU is left out (wall clock elsewhere);
- the small input runs `scale` times per timing so both timings are long
  against the clock's resolution;
- small and big runs alternate, five rounds after a warm-up, cheapest kept;
- 8x the input everywhere: linear ~8x, quadratic ~64x, limit 24x.

Measured under load (load avg ~15-35 on 32 cores): 60 linear ratios in
7.7-8.8. With the old quadratic code injected locally each test fails:
unequal formats (uncapped style bucket scan) ~90-104x, hidden separators
(hyphenator rescan per point) ~36x, float anchors (run walk restarted per
anchor) ~188x, PDF headings (left-out range scan per char) ~37x,
hyphenation 50-65x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:26:27 -04:00
383 changed files with 5356 additions and 98247 deletions
-33
View File
@@ -1,33 +0,0 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
-73
View File
@@ -1,73 +0,0 @@
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
# stay on their existing nightly/release schedule.
name: Printing
on:
pull_request:
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
push:
branches: [main]
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: printing-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
name: Print (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
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 libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Formatting
run: cargo fmt --all -- --check
- name: Native app and UI Clippy
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
- name: Native app and UI tests
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
- name: Browser compatibility
if: runner.os == 'Linux'
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
+1 -9
View File
@@ -10,11 +10,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|---|---|---|---|---|
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
@@ -73,8 +70,3 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
Generated
+39 -188
View File
@@ -241,7 +241,6 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -693,17 +692,6 @@ dependencies = [
"libc",
]
[[package]]
name = "chardetng"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "cipher"
version = "0.5.2"
@@ -830,12 +818,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -860,31 +842,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"egui",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1210,20 +1167,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1372,12 +1315,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1415,12 +1352,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1608,7 +1539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"foldhash",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1624,7 +1555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"foldhash",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1772,15 +1703,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1789,7 +1711,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -1798,7 +1720,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -1883,6 +1805,17 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "hayro-write"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
dependencies = [
"flate2",
"hayro-syntax",
"pdf-writer",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2214,13 +2147,15 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -2390,12 +2325,6 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
@@ -2470,15 +2399,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2876,16 +2796,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2946,17 +2856,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3896,10 +3795,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
]
[[package]]
@@ -4100,17 +3996,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5111,31 +4996,11 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "wordcraft"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"arboard",
"clipboard-win",
"craft-print",
"dispatch2",
"eframe",
"egui",
@@ -5149,24 +5014,20 @@ dependencies = [
"rfd",
"serde_json",
"windows",
"winit",
"winresource",
"wl-clipboard-rs",
"wordcraft-control-key",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-proof",
"wordcraft-ui-egui",
"zbus",
]
[[package]]
name = "wordcraft-cli"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-mcp",
@@ -5176,11 +5037,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.8.0"
version = "0.5.0"
[[package]]
name = "wordcraft-doc"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -5192,7 +5053,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"cfb",
"image",
@@ -5200,7 +5061,6 @@ dependencies = [
"miniz_oxide 0.8.9",
"proptest",
"serde_json",
"sha1",
"thiserror 2.0.21",
"wordcraft-doc",
"wordcraft-geom",
@@ -5208,7 +5068,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"aes",
"base64",
@@ -5230,11 +5090,8 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"cfb",
"encoding_rs",
"getrandom 0.3.4",
"image",
"js-sys",
"log",
@@ -5259,7 +5116,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -5272,13 +5129,9 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"cfb",
"chardetng",
"encoding_rs",
"image",
"js-sys",
"log",
"proptest",
"quick-xml",
@@ -5289,14 +5142,14 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"serde",
]
[[package]]
name = "wordcraft-layout"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"image",
"js-sys",
@@ -5315,7 +5168,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"log",
"serde_json",
@@ -5327,7 +5180,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -5335,14 +5188,14 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"craft-pdf",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"image",
"js-sys",
"krilla",
"kurbo",
"log",
"thiserror 2.0.21",
@@ -5355,7 +5208,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -5364,7 +5217,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"image",
"log",
@@ -5378,9 +5231,8 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"image",
@@ -5390,7 +5242,6 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"unicode-bidi",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
@@ -5404,7 +5255,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -5423,7 +5274,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -5536,7 +5387,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.5.0"
dependencies = [
"serde_json",
]
+4 -13
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
rust-version = "1.90"
repository = "https://github.com/storytold/wordcraft"
[workspace.dependencies]
@@ -52,17 +52,12 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
krilla = { version = "0.8", features = ["pdf"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
miniz_oxide = "0.8"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
encoding_rs = "0.8"
chardetng = "1"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
sha1 = "0.11"
@@ -78,7 +73,7 @@ egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
arboard = { version = "3.6", default-features = false }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -100,7 +95,3 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
+1 -53
View File
@@ -26,61 +26,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
unmodified and used under the MIT License; the copyright and permission notice is in the file.
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
package, bundled unmodified and used under the BSD 3-Clause License:
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
2024 Leonardo Araujo and Aline Benevides
Redistribution and use in source and binary forms, with or without modification, are
permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of
conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of
conditions and the following disclaimer in the documentation and/or other materials
provided with the distribution.
* Neither the name of the University of Campinas, of the University of Minho nor the names
of its contributors may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO
J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
Copyright (C) 2008-2011 Claudio Beccari
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
+4 -30
View File
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
@@ -151,7 +151,6 @@ WordCraft was designed to be driven by people *and* by AI agents.
```sh
claude mcp add wordcraft -- wordcraft-cli mcp # headless documents
# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
wordcraft --control 7981 & # or drive the running app…
claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981
```
@@ -188,7 +187,7 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| L0 | `wordcraft-geom` | units and measurements |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF (krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
| L5 | `wordcraft-mcp` | MCP server |
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
@@ -235,10 +234,6 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
The **32-bit** build uses OpenGL even when a DirectX 12 graphics card is present. On 32-bit
Windows, creating a DirectX 12 graphics pipeline fails immediately and the app exits before the
window appears. `WGPU_BACKEND=vulkan` (or `dx12`) still overrides that.
**If the app quits right after its window appears with "Parent device is lost" in the log:**
WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no
`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603).
@@ -272,38 +267,17 @@ instead, without setting anything. The log file records the choice (lines starti
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
(Ubuntu 24.04, Debian 13 and newer).
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
winit (the windowing library) supports dragging files into the window only on X11. To use the
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
```
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
|---|---|---|
| [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) |
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
#### Community packages
These packages are maintained by community members, not by the WordCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
emerge --ask app-office/wordcraft-bin
```
### FreeBSD
| Build | File |
+9 -335
View File
@@ -2,9 +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-17 · **Change:** trivial (Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; 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.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: Turkish interface landed; 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
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -15,8 +13,8 @@ agent control. This page is the summary; the assessment is
## Headline numbers (2026-10-10)
| Number | Value | Kind |
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|---|---|---|
| Ribbon/menu catalog coverage | **483 / 498 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -104,19 +102,17 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Spanish | es | partial | ~95% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% |
| French | fr | partial (interface) | ~95% |
| French | fr | none | 0% |
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
| Indonesian | id | none | 0% |
| Japanese | ja | partial | ~95% |
| German | de | partial (#574) | ~95% |
| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) |
| Vietnamese | vi | partial (#1434) | ~95% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
2,448-entry catalog, also without proofing.
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech (system Hunspell spelling, generic checks), chosen per run from its language. Word ships
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included.
Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -135,182 +131,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 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-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-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. |
| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it |
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
| 2026-10-14 | A file name that isn't valid Unicode, such as a Latin-1 `café.docx`, no longer crashes `wordcraft-cli` or the app (#1440). The app and the CLI's `convert`, `info`, `text`, `inspect`, `render`, `zotero` and `run --file` open such a file, and saving it without a new name (`run --file … --cmd file.save`, or Save in the app) writes back to it. Any other such argument is an error naming it, an output name included; the app exits with status 2 on a `--control` port that isn't valid Unicode |
| 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too |
| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) |
| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) |
| 2026-10-13 | Insert › Link has Word's Place in This Document: a link to a bookmark, a heading (through a hidden _Toc bookmark) or the top of the document, from the dialog's Link to: choice or `insert.link` with `bookmark`, `heading` or `top`; `link.targets` lists the places and Go To follows `_top` (#1234) |
| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested |
| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) |
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) |
| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. |
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
| 2026-10-14 | File › Print translated in all 18 interface languages — printer list, duplex, grayscale, discovery/submission feedback and print commands (18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs) |
| 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it |
| 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) |
| 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too |
| 2026-10-14 | `layout.pageSetup` with a `section` checks its values like Margins, Size and Columns (#1582): non-finite numbers and margins that leave no room for text are refused with nothing changed; page sides, column count and gap, header/footer distances and line-numbering values are clamped to the same ranges |
| 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port |
| 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") |
| 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped |
| 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word |
| 2026-10-14 | "Do you want to save changes?" answers from the keyboard after typing too (#1597): Save takes focus from the page when the prompt opens, so the arrow keys and Tab move between Save, Don't Save and Cancel instead of the caret behind it, and Enter answers |
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
| 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo |
| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet |
| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing |
| 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) |
| 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) |
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
| 2026-10-14 | Copied text keeps its formatting in other programs (#1523): Copy and Cut put HTML beside the plain text on the system clipboard for every selection, not only one holding an equation, so a browser, mail client or another word processor pastes bold, italic, fonts and sizes. RTF on the clipboard is not written yet |
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
| 2026-10-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged |
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake |
| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways |
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents |
| 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks |
| 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) |
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures |
| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 |
| 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) |
| 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing |
| 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. |
| 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style |
| 2026-10-12 | Table eraser beside vertically merged cells (#1039): the border between two cells merged down the same rows merges them into one cell that stays merged down those rows; a border whose two sides can't make one rectangle is hidden on both sides along the whole edge instead of refused |
| 2026-10-13 | Styles gallery for DOCX files that mark no style `w:qFormat` (LibreOffice) shows Word's usual quick styles: Normal, No Spacing, Headings, Title, Subtitle… (#1238) |
| 2026-10-13 | Pictures can be cropped on the page: Picture Format › Crop shows black crop handles; dragging one trims that side (a corner, two) while the picture keeps its scale and the visible part stays put, dragging back out restores it, and the cropped-away part shows under a dark dim. Esc, Enter, a click outside or Crop again keep the crop; each drag is one undo step |
| 2026-10-13 | Table Layout › Cell Size: the Height and Width boxes apply to every row and column the selection covers (a selected table changed only its last row and column), and to the caret's row and column without a selection (#1242) |
| 2026-10-12 | Forms protection: filling content controls under `w:edit="forms"` (#1077) — entries and typing inside fillable controls run, every other edit rolls back ("only the editable regions of this protected document can change"); `review.restrict` reports `formFields`; Find Next Region covers form fields (pane button shows for forms documents); a save/reopen keeps the protection, the filled values and the password guard |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli <command> --help` works for every command instead of failing with `unknown option --help` |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
| 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too |
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge |
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
| 2026-10-12 | PDFW: PDF export uses the shared craft-pdf writer for metadata, font preparation and serialization while retaining WordCraft layout, drawing, tagging, links and font-outline retry. Writer-only builds do not enable PDF import codecs. |
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
| 2026-10-13 | Keytips reach the table tabs as in Word: Alt, J, T for Table Design and Alt, J, L for Table Layout, then their controls' badges (#1183) |
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
| 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) |
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) |
| 2026-10-13 | A paragraph with more than 65,536 distinct formats keeps each one (#1340): the layout numbered a paragraph's formats with 16 bits, so every run past that drew with the 65,536th format's font, size, link and tracked-change marking |
| 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" |
| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together |
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
| 2026-10-13 | New comments, notes, text boxes and lists in a document whose file used the largest ids (#1338): they take a free id instead of overflowing (a crash) or reusing one already taken (a new list could pick up another's definition); `Document::try_add_part` and `Numbering::try_add_list` report ids running out instead |
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
| 2026-10-13 | An empty file opens (#1384): the zero-byte .docx that File Explorer's New › Microsoft Word Document makes opens as a new blank document (the region's paper, the editing language), and Save writes it back to that file instead of asking for a name |
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
| 2026-10-13 | Paragraph dialog over differently formatted paragraphs (#1346): a field they differ in shows blank (a check box neither on nor off, no direction picked) and OK leaves it as each paragraph has it; OK sends only the fields that changed, as one undo step, instead of giving every paragraph the caret paragraph's formatting in three undo steps |
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-12 | Czech proofing: cs-CZ spelling reads the installed Hunspell dictionary (`hunspell-cs` on Debian/Ubuntu), suggestions use runtime MAP/REP/TRY/KEY tables, generic repeated-word and spacing checks explain in Czech; Review › Language offers Czech/Čeština; the Czech interface writes new documents and opened TXT/Markdown in cs-CZ. Affix headers accept trailing comments. No system dictionary data or Czech hyphenation patterns bundled; parity estimates unchanged |
| 2026-10-12 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) |
| 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) |
| 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) |
| 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction |
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` |
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) |
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
| 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged |
| 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. |
| 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice |
| 2026-10-12 | Portuguese proofing: pt-BR spelling against the installed Hunspell `pt_BR` dictionary (VERO; read at runtime, not bundled) — the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD` to read it — suggestions tuned for Portuguese (missing accents first), conservative grammar rules with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause, attributed); Review › Language offers Português (Brasil); a pt-br interface writes new documents in pt-BR |
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
| 2026-10-14 | `.doc` notes, headers and text boxes read their text once (#1319): a story table that steps back no longer has many footnotes, endnotes, headers or text boxes copy the same text into a part each; a story starting inside an earlier one is dropped (a header or footer left empty is linked to the previous section's), so the stories together never hold more than the file's text |
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left |
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
@@ -321,11 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-14 | Open DrawingML freeform paths draw `a:tailEnd` at the final path segment |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
| 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) |
| 2026-10-13 | Markdown import keeps every link in a paragraph (#1452): a `[` nothing closes, bracketed text that is not a link and each link used up the bracket scans' look-ahead budget as if they had read the rest of the paragraph, so later links were plain text; a scan now costs what it reads, and brackets it has passed are looked up |
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) |
@@ -333,9 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) |
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing |
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along |
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
@@ -345,7 +166,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
| 2026-10-12 | Selection bar (#856): in the left margin beside body text a click selects the line, a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; dragging there selects whole lines and Shift+click extends by lines; the pointer turns to an arrow. Right margin for right-to-left paragraphs not yet |
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
| 2026-10-11 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
@@ -359,7 +179,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons |
| 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) |
| 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) |
| 2026-10-11 | Quick Access Toolbar KeyTips in the customized button order (Alt/F10 then a number), disabled-command checks and a saved File › Options switch (`ui.keytips`), #620 |
| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) |
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
@@ -371,17 +190,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
| 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources |
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
| 2026-10-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet |
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | Legacy table paragraph spacing: `allowSpaceOfSameStyleInTable` survives DOCX save; enabled modes 11/12/14 keep contextual same-style spacing in cells. Mode 15 and non-cell controls unchanged; measured against Word for Mac 16.113.3 |
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
@@ -397,135 +207,18 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
| 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) |
| 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web |
| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) |
| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) |
| 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 |
| 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles |
| 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save |
| 2026-10-14 | trivial | Progress log: consistent preferred cell widths |
| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) |
| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) |
| 2026-10-13 | trivial | Progress log: Insert Hyperlink › Place in This Document (#1234) |
| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) |
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
| 2026-10-14 | trivial | Progress log: print-area strings translated in every catalog |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) |
| 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) |
| 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) |
| 2026-10-14 | trivial | Progress log: Page Setup sections are validated (#1582) |
| 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) |
| 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) |
| 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) |
| 2026-10-14 | trivial | Progress log: Russian letter list labels |
| 2026-10-14 | trivial | Progress log: the save prompt takes the keyboard from the page (#1597) |
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
| 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves |
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) |
| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) |
| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) |
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) |
| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) |
| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) |
| 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) |
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
| 2026-10-13 | trivial | Progress log: space after a page break |
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) |
| 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) |
| 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) |
| 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows |
| 2026-10-13 | trivial | Progress log: Styles gallery fallback for DOCX files without qFormat (#1238) |
| 2026-10-13 | trivial | Progress log: crop handles on the canvas |
| 2026-10-13 | trivial | Progress log: cell size boxes act on the whole selection (#1242) |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) |
| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) |
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
| 2026-10-12 | trivial | Progress log: table style editor (#714) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
| 2026-10-11 | trivial | Progress log: the table context menu |
| 2026-10-12 | trivial | Progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#971) |
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
| 2026-10-14 | trivial | Progress log: Save As type list (#1399) |
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) |
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
| 2026-10-13 | trivial | Progress log: an empty .docx opens and saves back to itself (#1384) |
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) |
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) |
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
| 2026-10-12 | trivial | Progress log: new documents follow the system region (#838) |
| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage |
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
| 2026-10-11 | trivial | Progress log: German interface (#574) |
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
| 2026-10-13 | trivial | Progress log: Word 365 cover pages, positions and closed freeforms (#743) |
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
| 2026-10-11 | trivial | Progress log: Animation effects setting |
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
| 2026-10-13 | trivial | Progress log: every distinct format in a paragraph keeps its own layout style (#1340) |
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
| 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) |
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
@@ -536,21 +229,16 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
| 2026-10-11 | trivial | Progress log: content controls (#381) |
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
| 2026-10-13 | trivial | Progress log: free part, list and comment ids after a file's largest ones (#1338) |
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
| 2026-10-11 | trivial | Progress log: document grid (#391) |
| 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) |
| 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 |
| 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) |
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-13 | trivial | Progress log: Markdown backtick runs read in linear time (#1336) |
| 2026-10-13 | trivial | Progress log: Markdown brackets no longer turn later links into text (#1452) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged |
@@ -560,27 +248,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-13 | trivial | Swedish interface (`sv`): progress log and shipped languages |
| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages |
| 2026-10-13 | trivial | Croatian interface (`hr`): progress log and shipped languages |
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
| 2026-10-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) |
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | trivial | Legacy table paragraph spacing preserved; modes 11/12/14 versus 15 checked with original synthetic documents |
| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers |
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) |
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) |
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) |
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
@@ -591,4 +266,3 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
-1
View File
@@ -20,4 +20,3 @@ wordcraft-render = { workspace = true }
wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
-90
View File
@@ -1,90 +0,0 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--author", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("report.docx"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}
+22 -487
View File
@@ -1,12 +1,8 @@
//! `wordcraft-cli`: WordCraft from the command line.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod automation_args;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
@@ -16,10 +12,8 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
@@ -31,91 +25,23 @@ USAGE:
who tracked changes and comments are by)
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
MCP server on stdio (headless, or bridged to the app).
--automation-read-root / --automation-write-root confine
the files the agent's commands read (open, insert text
or picture, mail-merge data, templates…) and write (save,
export, merge output…) to DIR, links followed; a root left
out grants none of its access. They apply to the headless
server and are an error with --connect.
wordcraft-cli mcp [--connect HOST:PORT | --author NAME]
MCP server on stdio (headless, or bridged to the app)
wordcraft-cli zotero <command> <file> [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P]
run a Zotero command on a document (Zotero must be
running): addEditCitation, addEditBibliography,
addNote, refresh, removeCodes, setDocPrefs
(--cmd runs first, e.g. --cmd caret.docEnd)
wordcraft-cli [<command>] -h | --help this help
wordcraft-cli --version
Paper: documents created here, and HTML, Markdown and text files (which give no page size), use
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
";
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
fn open(path: &Path) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, _) = wordcraft_engine::io::open_path_in(path, None, &region)?;
let mut s = session(doc);
fn open(path: &str) -> Result<Session, String> {
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
let mut s = Session::new(doc);
s.path = Some(path.into());
Ok(s)
}
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
/// `pos` counts). `run` opens the value of `--file`.
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments.
/// Any other such argument is an error naming it.
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
let cmd = args.first().map(String::as_str).unwrap_or("");
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
let mut file = None;
for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() {
if a.to_str().is_none() {
if Some(i) != file_at {
return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy()));
}
file = Some(PathBuf::from(a));
}
}
Ok((args, file))
}
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
/// same paper (#1381).
fn session(doc: wordcraft_doc::Document) -> Session {
let mut s = Session::new(doc);
s.system_region = system_region();
s
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
}
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
Ok(String::from_utf8_lossy(&bytes).replace("\r\n", "\n"))
}
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
@@ -125,58 +51,6 @@ fn arg_value(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
enum OptionArg<'a> {
Absent,
Duplicate,
MissingValue,
Value(&'a str),
}
fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> {
let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name);
let Some((i, _)) = matches.next() else {
return OptionArg::Absent;
};
if matches.next().is_some() {
return OptionArg::Duplicate;
}
match args.get(i + 1) {
None => OptionArg::MissingValue,
Some(val) if val.starts_with("--") => OptionArg::MissingValue,
Some(val) => OptionArg::Value(val.as_str()),
}
}
fn parse_page_arg(command: &str, args: &[String]) -> Result<Option<u64>, String> {
match find_option_arg(args, "--page") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")),
OptionArg::MissingValue => Err(format!("{command}: --page requires a value")),
OptionArg::Value(val) => {
let n = val.parse::<u64>().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?;
if n == 0 {
return Err(format!("{command}: invalid --page `0`: page numbers start at 1"));
}
Ok(Some(n))
}
}
}
fn parse_scale_arg(command: &str, args: &[String]) -> Result<Option<f64>, String> {
match find_option_arg(args, "--scale") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")),
OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")),
OptionArg::Value(val) => {
let scale = val.parse::<f64>().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?;
if !scale.is_finite() || scale <= 0.0 {
return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number"));
}
Ok(Some(scale))
}
}
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
@@ -202,28 +76,8 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
Ok(())
}
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
/// that are left. A malformed root flag is an error, never a root silently left out.
fn automation_roots(
command: &str,
args: &[String],
) -> Result<(Option<std::sync::Arc<wordcraft_engine::file_access::AutomationRoots>>, Vec<String>), String> {
let mut found = automation_args::AutomationArgs::new(None, None);
let mut left = Vec::new();
let mut it = args.iter().cloned();
while let Some(arg) = it.next() {
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
left.push(arg);
}
}
let roots = wordcraft_engine::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
.map_err(|e| format!("{command}: {e}"))?;
Ok((roots, left))
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@@ -291,40 +145,20 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
Ok(())
}
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let (args, file) = text_args(args)?;
// Opens the file by its real name, which its text in `args` may have lost.
let open = |text: &str| open(file.as_deref().unwrap_or(Path::new(text)));
fn run(args: &[String]) -> Result<(), Failure> {
let cmd = args.first().map(String::as_str).unwrap_or("");
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
// Before options are checked or files and folders opened.
if wants_help(cmd, &rest) {
return write_stdout(|o| write!(o, "{USAGE}"));
}
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let paths: Vec<&String> = rest
.iter()
.enumerate()
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
.map(|(_, a)| a)
.collect();
let missing = || format!("missing argument\n\n{USAGE}");
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
let (i, o) = (pos(0)?, pos(1)?);
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
s.run("file.save", &json!({"path": o, "markup": markup}))
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
@@ -339,8 +173,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
"text" => {
validate_options("text", &rest, NO_OPTIONS)?;
let s = open(&pos(0)?)?;
let text = listed_text(&s.doc)?;
write_stdout(|o| writeln!(o, "{text}"))
write_stdout(|o| writeln!(o, "{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body)))
}
"inspect" => {
validate_options("inspect", &rest, NO_OPTIONS)?;
@@ -351,24 +184,22 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
"render" => {
validate_options("render", &rest, RENDER_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i)?;
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
validate_options("run", &rest, RUN_OPTIONS)?;
let page = parse_page_arg("run", &rest)?;
let scale = parse_scale_arg("run", &rest)?;
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
(Some(f), _) => open(&f)?,
(None, Some(t)) => {
let mut s = session(wordcraft_doc::Document::new());
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
s
}
_ => session(wordcraft_doc::Document::new()),
_ => Session::new(wordcraft_doc::Document::new()),
};
if let Some(name) = arg_value(&rest, "--author") {
set_author(&mut s, &name)?;
@@ -393,10 +224,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
if let Some(out) = arg_value(&rest, "--save") {
let r = if out.to_ascii_lowercase().ends_with(".png") {
let mut v = json!({"path": out});
if let Some(n) = page {
if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()) {
v["page"] = json!(n);
}
if let Some(k) = scale {
if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()) {
v["scale"] = json!(k);
}
s.run("file.exportPng", &v)
@@ -410,7 +241,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
"commands" => {
validate_options("commands", &rest, COMMANDS_OPTIONS)?;
let s = session(wordcraft_doc::Document::new());
let s = Session::new(wordcraft_doc::Document::new());
if rest.iter().any(|a| a == "--json") {
let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default();
write_stdout(|o| writeln!(o, "{list}"))
@@ -425,7 +256,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
"parity" => {
validate_options("parity", &rest, PARITY_OPTIONS)?;
let s = session(wordcraft_doc::Document::new());
let s = Session::new(wordcraft_doc::Document::new());
let p = wordcraft_engine::catalog::parity(&s.registry);
if rest.iter().any(|a| a == "--markdown") {
write_stdout(|o| write!(o, "{}", parity_markdown(&p)))
@@ -434,17 +265,9 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
}
"mcp" => {
let (roots, rest) = automation_roots("mcp", &rest)?;
validate_options("mcp", &rest, MCP_OPTIONS)?;
let author = arg_value(&rest, "--author");
let connect = arg_value(&rest, "--connect");
if roots.is_some() && connect.is_some() {
return Err(
"mcp: --automation-read-root and --automation-write-root confine the headless server; a running app is not confined by them"
.into(),
);
}
let backend: Box<dyn wordcraft_mcp::Backend> = match connect {
let backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
// The app's user name is the person's own setting; don't rewrite it from a flag.
Some(_) if author.is_some() => {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
@@ -458,13 +281,6 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
wordcraft_engine::file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("wordcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
headless = headless.with_automation_roots(Some(roots));
}
if let Some(name) = author {
set_author(&mut headless.session, &name)?;
}
@@ -556,7 +372,7 @@ fn parity_markdown(p: &Value) -> String {
}
fn main() -> ExitCode {
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
Err(Failure::Error(e)) => {
@@ -600,32 +416,6 @@ mod tests {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
}
assert!(!wants_help("frobnicate", &args(&["--help"])));
}
#[test]
fn usage_lists_exactly_the_subcommands() {
let listed: Vec<&str> = USAGE
.lines()
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
.filter(|word| !word.starts_with(['-', '[']))
.collect();
assert_eq!(listed, SUBCOMMANDS);
}
#[test]
fn text_keeps_bullet_and_number_labels() {
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
}
#[test]
fn author_flag_names_the_headless_session() {
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
@@ -638,259 +428,4 @@ mod tests {
// 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")));
}
#[test]
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
let (roots, left) =
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
.unwrap();
let roots = roots.unwrap();
assert_eq!(left, args(&["--author", "Claude"]));
assert!(roots.read_root().is_some() && roots.write_root().is_some());
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
// A root left out grants none of its access.
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
let roots = roots.unwrap();
assert!(roots.write_root().is_none());
assert!(
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
);
// No flags: nothing is confined and the arguments pass through.
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
assert!(roots.is_none());
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
for bad in [
args(&["--automation-read-root"]),
args(&["--automation-read-root", "--author"]),
args(&["--automation-write-root="]),
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
] {
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
}
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
// Through the command line: an error, not a server that quietly runs unconfined.
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[cfg(unix)]
#[test]
fn only_the_opened_file_may_be_other_than_unicode() {
use std::os::unix::ffi::OsStringExt;
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
// `OPENS_FILE` and the commands' own arguments agree.
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
.iter()
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
.collect();
opening.push(vec!["run".into(), "--file".into(), missing()]);
for raw in opening {
let e = run(&raw).unwrap_err().to_string();
assert!(e.contains("No such file"), "{raw:?}: {e}");
}
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
}
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
#[test]
fn render_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
// Non-numeric operands
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
Err("render: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
Err("render: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
Err("render: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
Err("render: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
Err("render: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
Err("render: invalid --scale `-2`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
Err("render: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
Err("render: duplicate --scale option".into())
);
}
#[test]
fn run_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
Err("run: --page requires a value".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
Err("run: --scale requires a value".into())
);
// Non-numeric operands
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
Err("run: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
Err("run: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
Err("run: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
Err("run: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
Err("run: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
Err("run: invalid --scale `-1`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
Err("run: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
Err("run: duplicate --page option".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
Err("run: duplicate --scale option".into())
);
}
#[test]
fn render_and_run_valid_options_and_defaults_produce_files() {
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let docx_path = dir.join("sample.docx");
// Create sample docx via `run --save sample.docx`
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
assert!(docx_path.exists());
let docx_str = docx_path.to_string_lossy().into_owned();
// 1. render with defaults
let out_default = dir.join("out_default.png");
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
assert!(out_default.exists());
// 2. render with valid --page and --scale
let out_valid = dir.join("out_valid.png");
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
assert!(out_valid.exists());
// 3. render with different flag order
let out_reordered = dir.join("out_reordered.png");
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
assert!(out_reordered.exists());
// 4. render failure leaves no output file
let out_invalid = dir.join("out_invalid.png");
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
assert!(!out_invalid.exists());
// 5. run --save out.png with defaults
let run_default = dir.join("run_default.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
assert!(run_default.exists());
// 6. run --save out.png with --page and --scale
let run_valid = dir.join("run_valid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
assert!(run_valid.exists());
// 7. run --save out.png with different flag order
let run_reordered = dir.join("run_reordered.png");
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
assert!(run_reordered.exists());
// 8. run --save out.png failure leaves no output file
let run_invalid = dir.join("run_invalid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
assert!(!run_invalid.exists());
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -1,53 +0,0 @@
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
use std::process::Command;
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
/// writes) and read back the page size of the JSON it saved.
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
std::fs::write(&src, HTML).unwrap();
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let sp = &doc["lastSection"];
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
}
/// Convert the sample to JSON in `region` and read back the page size.
fn page_size(region: &str) -> (f64, f64) {
page_size_of(region, &["convert", "{src}", "{out}"])
}
fn is_a4((w, h): (f64, f64)) -> bool {
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
}
#[test]
fn html_converts_onto_the_region_paper() {
let (w, h) = page_size("it-IT");
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
}
#[test]
fn every_run_session_follows_the_region() {
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
// so `file.new` gives the same paper whichever way the session began.
for args in [
&["run", "--cmd", "file.new", "--save", "{out}"][..],
&["run", "--template", "blank", "--save", "{out}"][..],
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
] {
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
}
}
-47
View File
@@ -1,47 +0,0 @@
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
//! or files and folders opened (#968).
use std::process::{Command, Output};
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn cli(args: &[&str]) -> Output {
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
fn assert_prints_usage(args: &[&str]) {
let out = cli(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
}
#[test]
fn every_subcommand_prints_usage_for_help_and_h() {
assert_prints_usage(&["--help"]);
assert_prints_usage(&["-h"]);
for sub in SUBCOMMANDS {
for flag in ["--help", "-h"] {
assert_prints_usage(&[sub, flag]);
}
}
}
#[test]
fn help_comes_before_opening_files_and_folders() {
assert_prints_usage(&["info", "missing.docx", "--help"]);
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
}
#[test]
fn misspelled_options_and_unknown_commands_still_fail() {
let out = cli(&["info", "--hepl"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
let out = cli(&["frobnicate", "--help"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
}
-76
View File
@@ -1,76 +0,0 @@
//! 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;
use std::path::PathBuf;
use std::process::{Command, Output};
fn cli(args: &[OsString]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
fn folder(name: &str) -> (PathBuf, OsString) {
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&file, "Bonjour\n").unwrap();
(dir, file.into_os_string())
}
fn ok(out: &Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn the_file_a_command_opens_may_have_any_name() {
let (dir, file) = folder("open");
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
let md = dir.join("out.md");
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
let saved = dir.join("run.txt");
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
// Saving without a path writes back to the file under its real name, not a lossy one.
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
args.extend(["--cmd".into(), cmd.into()]);
}
ok(&cli(&args));
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
for (args, n) in [
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
] {
let out = cli(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
let shown = args[n - 1].to_string_lossy();
assert!(shown.contains('\u{FFFD}'));
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
}
assert!(!std::path::Path::new(&out_name).exists());
let _ = std::fs::remove_dir_all(dir);
}
@@ -1,31 +0,0 @@
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
//! the next commands type (#965).
use std::process::Command;
/// A 1x1 PNG.
const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
std::fs::write(&pic, PNG).unwrap();
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cli.args(["run", "--template", "blank"]);
for c in cmds {
cli.args(["--cmd", c]);
}
let run = cli.arg("--save").arg(&out).output().unwrap();
let docx = std::fs::read(&out);
let _ = std::fs::remove_dir_all(&dir);
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
}
-42
View File
@@ -1,42 +0,0 @@
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
use std::process::Command;
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
}
#[test]
fn text_omits_tracked_deletions() {
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.docx");
let path_s = path.to_str().unwrap();
let make = cli()
.args([
"run",
"--save",
path_s,
"--cmd",
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
"--cmd",
r#"review.trackChanges={"value":true}"#,
"--cmd",
r#"select.text={"text":"Alice Smith, Partner"}"#,
"--cmd",
r#"text.insert={"text":"Bob Jones, Director"}"#,
])
.output()
.expect("run");
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
let out = cli().args(["text", path_s]).output().expect("text");
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
assert!(!stdout.contains("Alice Smith"), "{stdout}");
let _ = std::fs::remove_dir_all(&dir);
}
+20 -272
View File
@@ -1,17 +1,16 @@
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
//! web runner.
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wordcraft_engine::Session;
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
mod font_store;
const CANVAS_ID: &str = "wordcraft_canvas";
const LOADING_ID: &str = "wordcraft_loading";
@@ -28,8 +27,7 @@ pub fn start() {
return;
};
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
if let Err(e) = guard_unload(dirty.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -49,27 +47,13 @@ pub fn start() {
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
font_store::load_all(cc.egui_ctx.clone());
let inbox: Inbox = Inbox::default();
let pasted: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
let sample = query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
app.autosave = false;
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
Ok(Box::new(WebShell { app, inbox, messages }))
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
@@ -91,7 +75,6 @@ fn query() -> String {
struct WebShell {
app: WordApp,
inbox: Inbox,
messages: Rc<RefCell<Vec<String>>>,
}
impl eframe::App for WebShell {
@@ -111,10 +94,6 @@ impl eframe::App for WebShell {
}
});
}
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
}
self.app.logic(ctx);
}
@@ -134,11 +113,10 @@ const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
/// the inbox like dropped ones. A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
@@ -159,7 +137,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The engine reads the format from the bytes; the name is ignored.
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
@@ -175,11 +153,8 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
let open_inbox = inbox.clone();
let save_ctx = ctx.clone();
let font_ctx = ctx.clone();
let font_messages = messages.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
@@ -204,120 +179,17 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
async_saves: Some(saves.clone()),
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
download: Some(Box::new(download)),
print: Some(Box::new(print_pdf)),
inbox: Some(inbox),
on_dirty: Some(Box::new(move |d| dirty.set(d))),
animations_enabled: Some(animations_enabled()),
store_font: Some(Box::new(font_store::store)),
forget_font: Some(Box::new(font_store::forget)),
load_system_fonts: Some(Box::new(move || {
load_system_fonts(font_ctx.clone(), font_messages.clone());
})),
..Default::default()
}
}
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
const MAX_SYSTEM_FONTS: usize = 500;
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
wasm_bindgen_futures::spawn_local(async move {
let Some(window) = web_sys::window() else {
return;
};
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
Ok(val) if val.is_function() => js_sys::Function::from(val),
_ => {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_promise = match query_fn.call0(&window) {
Ok(p) => js_sys::Promise::from(p),
Err(e) => {
log::warn!("queryLocalFonts call failed: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
Ok(val) => val,
Err(e) => {
log::warn!("queryLocalFonts promise rejected: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let font_list = js_sys::Array::from(&fonts_val);
let len = font_list.length();
let mut loaded = 0usize;
let mut total_bytes = 0usize;
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
break;
}
let font_data = font_list.get(i);
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
continue;
};
if !blob_fn.is_function() {
continue;
}
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
continue;
};
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
continue;
};
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
continue;
};
// Skip a face too big on its own or for what is left of the budget, before reading it.
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
continue;
}
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
continue;
};
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
continue;
}
total_bytes = total_bytes.saturating_add(bytes.len());
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_else(|| format!("local-font-{i}"));
let name = format!("{ps_name}.ttf");
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
Ok(_) => loaded += 1,
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
Err(e) => log::warn!("local font {name} skipped: {e}"),
}
}
if loaded > 0 {
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
messages.borrow_mut().push(msg);
} else {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
}
ctx.request_repaint();
});
}
/// Whether the system has animation effects on, as the browser passes it on: off while the
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
/// query is made once and asked every frame; it follows the system's setting by itself.
@@ -328,11 +200,10 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
// A save still in the browser's picker hasn't written anything yet.
if dirty.get() || saves.pending() > 0 {
if dirty.get() {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -344,143 +215,20 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String>
Ok(())
}
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
let window = web_sys::window().ok_or("no window")?;
// On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.),
// `showSaveFilePicker` lets the user choose the save location and filename natively.
if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker"))
&& let Ok(picker_fn) = picker_val.dyn_into::<js_sys::Function>()
{
match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) {
Ok(()) => return Ok(()),
Err(e) => {
log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download");
}
}
}
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
trigger_anchor_download(&window, &name, bytes)
}
fn trigger_save_file_picker(
saves: &AsyncSaves,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
name: &str,
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
let accept_obj = js_sys::Object::new();
let exts_arr = js_sys::Array::new();
exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext));
let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr);
// Our own neutral description: the format's name, as its extension says it.
let type_desc = match ext.as_str() {
"htm" => "HTML".to_string(),
"txt" => "Text".to_string(),
e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(),
_ => "Document".to_string(),
};
let type_obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc));
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj);
let types_arr = js_sys::Array::new();
types_arr.push(&type_obj);
let picker_opts = js_sys::Object::new();
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name));
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr);
// Call showSaveFilePicker synchronously while transient user activation is active:
let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?;
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
let bytes_vec = bytes.to_vec();
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
let ticket = saves.start();
wasm_bindgen_futures::spawn_local(async move {
// The document counts as saved only once the file is written and closed.
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
Ok(()) => {
log::info!("saved {name} through the save picker");
SaveOutcome::Saved
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
SaveOutcome::Failed(e)
}
},
Err(e) => {
let err_name =
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if err_name == "AbortError" {
log::debug!("save picker cancelled");
SaveOutcome::Cancelled
} else {
log::error!("showSaveFilePicker error: {e:?}");
SaveOutcome::Failed(format!("{e:?}"))
}
}
};
saves.finish(ticket, outcome);
ctx.request_repaint();
});
Ok(())
}
async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable"))
.map_err(js)?
.dyn_into()
.map_err(|_| "createWritable is not a function".to_string())?;
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write"))
.map_err(js)?
.dyn_into()
.map_err(|_| "write is not a function".to_string())?;
let uint8 = js_sys::Uint8Array::from(bytes);
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close"))
.map_err(js)?
.dyn_into()
.map_err(|_| "close is not a function".to_string())?;
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
Ok(())
}
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(name));
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
+1 -1
View File
@@ -36,7 +36,7 @@ async fn open() -> Result<IdbDatabase, String> {
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
let upgrade_req = req.clone();
let upgrade = Closure::<dyn FnMut()>::new(move || {
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
&& !db.object_store_names().contains(STORE)
&& let Err(e) = db.create_object_store(STORE)
{
+7 -18
View File
@@ -13,7 +13,6 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-proof = { workspace = true }
# Fonts the user added (#582) load from the settings folder at start-up.
wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
@@ -36,39 +35,26 @@ winresource = "0.1"
fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder"] }
# at this version; this adds only the `NSAccessibility` bindings.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
objc2 = "0.6"
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
open = "5"
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
# only the `UI_ViewManagement` bindings, and `System_UserProfile` for the home region new
# documents take their paper and units from (#838).
windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfile"] }
# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard
# doesn't read. Already in the build through arboard, at this version.
clipboard-win = { version = "5.4", features = ["std"] }
# only the `UI_ViewManagement` bindings.
windows = { version = "0.62", features = ["UI_ViewManagement"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
@@ -78,3 +64,6 @@ pollster = "0.4"
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
open = "5.4"
-215
View File
@@ -1,215 +0,0 @@
//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste,
//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and
//! copied files, and the names of the formats there for a paste that finds nothing it can use.
//!
//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma,
//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies
//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
//!
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
//! with equations as MathML (#816; pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
//! with OMML, #1297) it can't, so they aren't offered.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
thread_local! {
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
pub fn set_html(html: &str, text: &str) -> bool {
if !platform::can_set() {
return false;
}
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
*slot = None;
false
}
None => false,
}
})
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
}
/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large.
pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
return None;
}
// RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters.
let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string();
s.trim_start().starts_with("{\\rtf").then_some(s)
}
/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows).
pub fn formats() -> Vec<String> {
platform::formats()
}
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::warn!("clipboard: {e}");
return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})")));
}
};
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
Err(arboard::Error::ConversionFailure) => Some(
platform::encoded_picture()
.map(ClipboardPicture::Encoded)
.unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())),
),
Err(e) => {
log::warn!("clipboard picture: {e}");
platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string())))
}
}
}
/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first.
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"];
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
}
/// One MIME type's bytes, at most `cap`; `None` when absent or larger.
fn read(mime: &str, cap: u64) -> Option<Vec<u8>> {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut out = Vec::new();
pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= cap).then_some(out)
}
pub fn formats() -> Vec<String> {
if !wayland() {
return Vec::new();
}
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
pub fn can_set() -> bool {
!wayland()
|| !matches!(
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
)
}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
}
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
// Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…).
EnumFormats::new().take(64).filter_map(raw::format_name_big).collect()
}
pub fn rtf() -> Option<Vec<u8>> {
let format = raw::register_format("Rich Text Format")?;
let _open = Clipboard::new_attempts(10).ok()?;
if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) {
return None;
}
let mut out = Vec::new();
raw::get_vec(format.get(), &mut out).ok()?;
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
mod platform {
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
+1 -10
View File
@@ -484,16 +484,7 @@ mod tests {
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
drop(taken);
// Bound: the key file holds the key that opens the port.
let mut free_port = port.wrapping_add(1).max(1024);
let mut started = None;
for _ in 0..100 {
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
started = Some((res, free_port));
break;
}
free_port = free_port.wrapping_add(1).max(1024);
}
let ((rx, key_file), port) = started.unwrap();
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
let key_file = key_file.unwrap();
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
let key = std::fs::read_to_string(key_file.path()).unwrap();
+1 -3
View File
@@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
/// are ignored, as the XDG Base Directory spec says.
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
#[cfg(any(all(unix, not(target_os = "macos")), test))]
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let absolute = |s: &&str| Path::new(s).is_absolute();
@@ -130,7 +130,6 @@ mod xdg {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::PathBuf;
use super::*;
@@ -164,7 +163,6 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
let p = |s: &str| PathBuf::from(s);
+3 -57
View File
@@ -58,11 +58,8 @@ impl Launcher {
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Save { name } => {
let mut dialog = dialog.set_file_name(name.clone());
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
Box::pin(dialog.save_file())
let exts = save_exts(&name);
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
}
};
let ctx = self.ctx.clone();
@@ -161,60 +158,9 @@ fn save_exts(name: &str) -> Vec<String> {
exts
}
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
/// [`save_exts`] for why the order matters).
///
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
/// in the name box, the extension in the name wins, as the format is chosen by extension.
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
}
/// A readable name for a save format's extension.
fn format_name(ext: &str) -> String {
match ext {
"docx" => "Word Document".into(),
"docm" => "Word Macro-Enabled Document".into(),
"dotx" => "Word Template".into(),
"dotm" => "Word Macro-Enabled Template".into(),
"pdf" => "PDF".into(),
"txt" => "Plain Text".into(),
"md" => "Markdown".into(),
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
"xml" => "XML".into(),
other => other.to_ascii_uppercase(),
}
}
#[cfg(test)]
mod tests {
use super::{save_exts, save_filters};
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
fn save_dialog_lists_one_named_type_per_format() {
let filters = save_filters("report.odt");
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
assert_eq!(filters[0].1, vec!["odt".to_string()]);
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
for e in wordcraft_engine::io::SAVE_EXTS {
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
}
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
labels.sort();
labels.dedup();
assert_eq!(labels.len(), filters.len(), "labels are distinct");
// Unknown names fall back to Word's format first.
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
}
use super::save_exts;
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
/// name the user types without one is saved in the document's format instead of coming back
+19 -46
View File
@@ -20,28 +20,18 @@ pub struct Dx12Adapter {
/// The backends to initialize on Windows.
///
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
/// wins. Otherwise DirectX 12 only, as long as `dx12_graphics` is set and `dx12_adapters` (what a
/// DirectX 12-only probe found) contains a usable hardware GPU. Without one, OpenGL is the way to
/// the GPU: graphics chips whose driver has no DirectX 12 support (Intel 4th generation on Windows
/// 11, #316) still have an OpenGL driver, and GL can't reach the AMD driver crash described in
/// [`configure_backends`], because an AMD GPU with a driver shows up as a DirectX 12 adapter and
/// keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays enabled next to GL as a last
/// resort for machines without an OpenGL driver either (virtual machines): wgpu prefers any GPU
/// over a software adapter, so it's only picked when GL has nothing better.
///
/// `dx12_graphics` is false on 32-bit Windows. wgpu creates the graphics pipeline through
/// `ID3D12Device2::CreatePipelineState`, and that stream is aligned to 8 bytes. The 32-bit runtime
/// expects pointer alignment (4), so creation returns `E_INVALIDARG` ("The parameter is incorrect")
/// and the process exits before the first frame. Compute pipelines use the older API and succeed,
/// which is why startup gets as far as the egui pipeline and then quits. OpenGL does not use that
/// stream.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter], dx12_graphics: bool) -> Backends {
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
/// adapter, so it's only picked when GL has nothing better.
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
if let Some(backends) = backend_override {
return backends;
}
if !dx12_graphics {
return Backends::GL;
}
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
Backends::DX12
@@ -104,16 +94,9 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
backends
}
None => {
// 32-bit: skip the DirectX 12 probe. A capable GPU would be selected, then
// CreatePipelineState fails and the app exits with no window.
let dx12_graphics = cfg!(not(target_arch = "x86"));
let adapters = if dx12_graphics { probe_dx12(options) } else { Vec::new() };
let backends = choose_backends(None, &adapters, dx12_graphics);
if dx12_graphics {
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
} else {
log::info!("graphics: 32-bit Windows uses OpenGL; DirectX 12 graphics pipeline creation fails");
}
let adapters = probe_dx12(options);
let backends = choose_backends(None, &adapters);
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
backends
}
};
@@ -174,7 +157,7 @@ mod tests {
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
}
#[test]
@@ -182,8 +165,8 @@ mod tests {
let warp_only = [adapter(DeviceType::Cpu, true)];
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
// Whatever the probe would have found, the override wins (the probe doesn't run then).
assert_eq!(choose_backends(Some(backends), &[], true), backends);
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
assert_eq!(choose_backends(Some(backends), &[]), backends);
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
assert_eq!(configured_backends(backends), backends);
}
}
@@ -209,23 +192,13 @@ mod tests {
fn gl_when_dx12_has_no_usable_gpu() {
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
let warp_only = [adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[], true), Backends::GL);
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
assert_eq!(choose_backends(None, &[]), Backends::GL);
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
assert_eq!(choose_backends(None, &weak_gpu, true), Backends::GL);
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
assert_eq!(choose_backends(None, &mixed, true), Backends::DX12);
}
/// 32-bit Windows: a usable DirectX 12 GPU still starts on OpenGL. `WGPU_BACKEND` still wins.
#[test]
fn x86_uses_opengl_even_with_a_usable_gpu() {
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
assert_eq!(choose_backends(None, &gpu, false), Backends::GL);
assert_eq!(choose_backends(Some(Backends::DX12), &gpu, false), Backends::DX12);
assert_eq!(choose_backends(Some(Backends::VULKAN), &[], false), Backends::VULKAN);
assert_eq!(configured_backends(choose_backends(None, &gpu, false)), Backends::GL);
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
}
}
+36 -250
View File
@@ -1,6 +1,6 @@
//! WordCraft desktop app.
//!
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
//!
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
@@ -12,7 +12,6 @@
mod animations;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
mod control_server;
mod default_apps;
mod file_dialogs;
@@ -20,19 +19,17 @@ mod file_dialogs;
mod graphics;
mod logging;
mod print;
mod region;
#[cfg(target_os = "macos")]
mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
/// The app, the restored window geometry until the first frame has checked it, the control
/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the
/// documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
@@ -47,29 +44,16 @@ impl eframe::App for App {
apple_events::poll(&self.4, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
ctx.send_viewport_cmd(command);
}
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
} else if closing || self.0.quit_requested {
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
// the close is allowed, take that path instead of dropping the window on this turn.
@@ -93,7 +77,6 @@ impl eframe::App for App {
self.3.show(frame);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
if let Some(key_file) = &self.2 {
key_file.remove();
@@ -111,16 +94,11 @@ impl eframe::App for App {
#[cfg(target_os = "macos")]
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
use std::sync::atomic::{AtomicBool, Ordering};
if app.printing.busy() {
return;
}
static QUIT: AtomicBool = AtomicBool::new(false);
if QUIT.swap(true, Ordering::Relaxed) {
return;
}
log::info!("window close: quitting");
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
print::finish_pending_jobs();
save_prefs(app);
if let Some(key_file) = key_file {
key_file.remove();
@@ -145,21 +123,6 @@ fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).
fn user_dict_path() -> Option<std::path::PathBuf> {
wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))
}
fn load_user_dictionary(app: &mut WordApp) {
if !prefs_enabled() {
return;
}
if let Some(p) = user_dict_path() {
wordcraft_proof::load_user_dictionary(&p);
app.session.user_dict_path = Some(p);
}
}
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
@@ -204,56 +167,44 @@ fn save_prefs(app: &WordApp) {
return;
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
let _ = std::fs::write(&p, bytes);
}
}
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
/// interrupted write leaves the previous preferences in place.
fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the
/// system's animation setting (`animations`), and making WordCraft the default app for Word
/// documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
printers: Some(print::discover()),
print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)),
print: Some(print::hook()),
clipboard_picture: Some(Box::new(clipboard_picture)),
make_default_app: default_apps::hook(),
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
..Default::default()
}
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
@@ -263,59 +214,11 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
const USAGE: &str = "\
wordcraft — the WordCraft desktop app
USAGE:
wordcraft [--sample] [--control PORT] [FILE…]
OPTIONS:
--sample open the sample document
--control PORT start the localhost control server on PORT, for agents and scripts
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
--version print the version
-h, --help print this help
";
/// The commit the build came from (short), or `dev`.
fn build_sha() -> &'static str {
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
}
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
/// one (#1440). A `--control` port that isn't is an error.
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
let (mut args, mut files) = (Vec::new(), Vec::new());
for (i, a) in std::env::args_os().skip(1).enumerate() {
match a.into_string() {
Ok(a) => args.push(a),
Err(a) if args.last().is_some_and(|p| p == "--control") => {
// Unlike `eprintln!`, never panics.
let (n, lossy) = (i + 1, a.to_string_lossy());
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
std::process::exit(2);
}
Err(a) => files.push(a.into()),
}
}
(args, files)
}
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => log::warn!("{name}: {e}"),
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@@ -323,38 +226,18 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let (args, other_files) = text_args();
let mut bad_control: Option<Option<String>> = None;
let mut args = args.into_iter().peekable();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
"--control" => {
let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));
match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {
Some(p) => control_port = Some(p.get()),
None => bad_control = Some(port),
}
}
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" => {
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
return Ok(());
}
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
"--help" | "-h" => {
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return Ok(());
}
_ => files.push(a),
}
}
// After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.
if let Some(port) = bad_control {
let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));
std::process::exit(2);
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
@@ -385,8 +268,8 @@ fn main() -> eframe::Result {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
// DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with
// FXC, which needs no DLL of ours (#603; see graphics.rs).
// DirectX 12 has no GPU driver, and compile DirectX 12 shaders with FXC, which needs no
// DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
load_user_fonts();
@@ -394,16 +277,6 @@ fn main() -> eframe::Result {
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -414,38 +287,13 @@ fn main() -> eframe::Result {
"WordCraft",
options,
Box::new(move |cc| {
// The system region picks the new document's paper, the units and the decimal
// separator (#838).
let region = region::system();
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx));
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
&& let Some(dir) = wordcraft_control_key::settings_dir()
{
let ctx = cc.egui_ctx.clone();
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
log::warn!("local recovery unavailable: {e}");
}
}
app.ui.window = restored;
app.integrated_titlebar = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
if let Some(bar) = traffic_lights::title_bar() {
log::info!("title bar: {bar:?}");
app.mac_titlebar = bar;
}
let mut key_file = None;
if let Some(port) = control_port
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
@@ -453,24 +301,14 @@ fn main() -> eframe::Result {
app = app.with_control(rx);
key_file = file;
}
// Nothing to open (no files, no sample, no control channel driving the editor): start
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
// macOS hands over later open on top of it and close it (`apple_events`).
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
log::warn!("{f}: {e}");
}
}
for f in other_files {
open_non_unicode(&mut app, &f);
}
Ok(Box::new(App(
app,
// With nothing restored, the window the system placed is checked all the same.
Some(restored.unwrap_or_default()),
restored,
key_file,
dialogs,
#[cfg(target_os = "macos")]
@@ -479,55 +317,3 @@ fn main() -> eframe::Result {
}),
)
}
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
use super::*;
use std::os::unix::ffi::OsStringExt;
#[test]
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&path, "Bonjour").unwrap();
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
open_non_unicode(&mut app, &path);
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
app.run("caret.docEnd", serde_json::json!({})).unwrap();
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
app.run("file.save", serde_json::json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
// A file that can't be read is a warning, as one that fails to open by name is.
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod prefs_tests {
use super::write_prefs;
#[test]
fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {
let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ui.json");
std::fs::write(&path, b"old").unwrap();
write_prefs(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// A directory squatting on the temporary name makes the preparation step fail.
std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();
assert!(write_prefs(&path, b"newer").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
}
+211 -58
View File
@@ -1,76 +1,229 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
//!
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
//! prints from there through the system print dialog.
//!
//! The viewer reads the file after we return, so it can't be removed right away: files older than
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
//!
//! [`Services::print`]: wordcraft_ui_egui::Services::print
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Temporary print files start with this and end with `.pdf`.
const PREFIX: &str = "wordcraft-print-";
/// How long a print file is kept for its viewer before the clean-up removes it.
const KEEP: Duration = Duration::from_secs(60 * 60);
/// The `Services::print` hook.
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
/// The desktop print hook; also clears print files left over from earlier runs.
pub fn hook() -> Hook {
let dir = std::env::temp_dir();
spawn_cleanup(dir.clone());
Box::new(move |bytes| {
let r = print_with(&dir, bytes, open_viewer);
spawn_cleanup(dir.clone());
r
})
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
/// is removed again.
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
open(&path).inspect_err(|_| {
let _ = std::fs::remove_file(&path);
})
}
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
/// follows a link at) an existing path; readable by the user only where the OS supports it.
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
for _ in 0..16 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
return match file.write_all(bytes) {
Ok(()) => Ok(path),
Err(e) => {
drop(file);
let _ = std::fs::remove_file(&path);
Err(e)
}
};
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
}
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
/// Other files, links and files it may not remove are left alone.
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed = 0;
for entry in entries.flatten() {
if !is_print_file(&entry.file_name().to_string_lossy()) {
continue;
}
// `DirEntry::metadata` doesn't follow links.
let Ok(meta) = entry.metadata() else { continue };
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
removed
}
fn is_print_file(name: &str) -> bool {
name.starts_with(PREFIX) && name.ends_with(".pdf")
}
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
fn spawn_cleanup(dir: PathBuf) {
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
let n = remove_stale(&dir, SystemTime::now(), KEEP);
if n > 0 {
log::info!("removed {n} old print file(s)");
}
});
if let Err(e) = spawned {
log::warn!("print clean-up: {e}");
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
collate,
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "WordCraft document".into(),
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
#[cfg(target_os = "macos")]
fn open_viewer(path: &Path) -> Result<(), String> {
let mut child =
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
let path = path.to_path_buf();
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
if !child.wait().is_ok_and(|s| s.success())
&& let Err(e) = open::that(&path)
{
log::error!("print: couldn't open {}: {e}", path.display());
}
receiver
})
});
if let Err(e) = waiter {
log::warn!("print: {e}");
}
Ok(())
}
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
/// the file association on Windows).
#[cfg(not(target_os = "macos"))]
fn open_viewer(path: &Path) -> Result<(), String> {
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::cell::RefCell;
use std::rc::Rc;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Services, WordApp};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
/// start is reported in the status bar and leaves no file behind.
#[test]
fn shutdown_waits_for_pending_submission_workers() {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
fn file_print_opens_a_pdf_file() {
let dir = temp_dir("open");
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
let seen = opened.clone();
let hook_dir = dir.clone();
let services = Services {
print: Some(Box::new(move |bytes| {
print_with(&hook_dir, bytes, |path| {
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
Ok(())
})
})),
..Default::default()
};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
app.run("text.insert", json!({"text": "Print me"})).unwrap();
app.run("file.print", json!({})).unwrap();
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
app.run("ui.print", json!({})).unwrap();
app.run("ui.print", json!({})).unwrap();
let opened = opened.borrow();
assert_eq!(opened.len(), 2, "each print opens the viewer once");
for (path, bytes) in opened.iter() {
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
assert_eq!(path.parent(), Some(dir.as_path()));
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
}
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
let mut app = WordApp::new(
Session::new(wordcraft_doc::Document::new()),
Services {
print: Some(Box::new({
let dir = dir.clone();
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
})),
..Default::default()
},
);
let e = app.run("ui.print", json!({})).unwrap_err();
assert!(e.contains("no PDF viewer"), "{e}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
let _ = std::fs::remove_dir_all(&dir);
}
/// The clean-up removes old print files only.
#[test]
fn stale_print_files_are_removed() {
let dir = temp_dir("stale");
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
let other = dir.join("notes.pdf");
std::fs::write(&other, b"keep").unwrap();
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
assert!(fresh.exists() && other.exists() && !old.exists());
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
let _ = std::fs::remove_dir_all(&dir);
}
}
-30
View File
@@ -1,30 +0,0 @@
//! The system region new documents follow (#838): the paper File › New starts on, the default
//! measurement unit and the decimal separator.
//!
//! - Windows: the preferred language with the home location from Settings › Time & language ›
//! Region (`GlobalizationPreferences.HomeGeographicRegion`, a safe Windows Runtime API), since
//! the language list alone says `en-US` for an English interface in Poland.
//! - Elsewhere: the locale the UI crate reads ([`wordcraft_ui_egui::i18n::system_region`]).
/// The system region.
pub fn system() -> wordcraft_engine::Region {
// Runs without preferences (agents' and screenshot runs) behave the same on every machine:
// en-US unless `WORDCRAFT_REGION` says otherwise.
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() && std::env::var_os("WORDCRAFT_REGION").is_none() {
return wordcraft_engine::Region::default();
}
let region = wordcraft_ui_egui::i18n::system_region();
#[cfg(target_os = "windows")]
{
if std::env::var_os("WORDCRAFT_REGION").is_none() {
return match windows::System::UserProfile::GlobalizationPreferences::HomeGeographicRegion() {
Ok(home) => region.with_territory(&home.to_string()),
Err(e) => {
log::debug!("system home region: {e}");
region
}
};
}
}
region
}
-26
View File
@@ -1,26 +0,0 @@
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
//!
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowButton};
use wordcraft_ui_egui::chrome::MacTitleBar;
/// The title bar around the app window's traffic lights, or `None` before the window exists or
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
pub fn title_bar() -> Option<MacTitleBar> {
let mtm = MainThreadMarker::new()?;
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// In window coordinates, which count up from the bottom.
let frame = zoom.convertRect_toView(zoom.bounds(), None);
let window_height = window.frame().size.height;
let right = frame.origin.x + frame.size.width;
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
return None;
}
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
}
-58
View File
@@ -1,58 +0,0 @@
//! `wordcraft --control` checks its port before the window opens (#1426).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[&str]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
// A bad `--control` must not fall back to this port either.
.env("WORDCRAFT_CONTROL_PORT", "7981")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn a_bad_port_is_an_error_naming_it() {
let cases: [(&[&str], &str); 8] = [
(&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""),
(&["--control", "abc"], ", got \"abc\""),
(&["--control", "79811"], ", got \"79811\""),
(&["--control", "0"], ", got \"0\""),
(&["--control", "-5"], ", got \"-5\""),
(&["--control", "abc", "--control", "7981"], ", got \"abc\""),
(&["--control"], ""),
(&["--control", "--sample"], ""),
];
for (args, got) in cases {
let out = app(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}");
}
}
#[test]
fn a_flag_after_control_is_read_as_a_flag() {
let out = app(&["--control", "--version"]);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
}
-42
View File
@@ -1,42 +0,0 @@
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(flag: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.arg(flag)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {flag} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn help_and_h_print_the_options_and_exit() {
for flag in ["--help", "-h"] {
let out = app(flag);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
}
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
}
}
-53
View File
@@ -1,53 +0,0 @@
//! 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;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[OsString]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
/// macOS, where the app opens its window without a display variable and so keeps running.
#[cfg(not(target_os = "macos"))]
#[test]
fn a_file_with_such_a_name_starts_the_app() {
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("panic"), "stderr: {stderr}");
// It went on to open the window, which fails without a display.
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
}
#[test]
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
}
-431
View File
@@ -1,431 +0,0 @@
% title: Hyphenation patterns for Italian
% copyright: Copyright (C) 2008-2011 Claudio Beccari
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Italian
% tag: it
% version: 4.9 2014/04/22
% authors:
% -
% name: Claudio Beccari
% contact: claudio.beccari (at) gmail.com
% licence:
% - This file is available under any of the following licences:
% -
% name: LPPL
% version: 1.3
% or_later: true
% url: http://www.latex-project.org/lppl.txt
% status: maintained
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
% -
% name: MIT
% url: https://opensource.org/licenses/MIT
% text: >
% Permission is hereby granted, free of charge, to any person
% obtaining a copy of this software and associated documentation
% files (the "Software"), to deal in the Software without
% restriction, including without limitation the rights to use,
% copy, modify, merge, publish, distribute, sublicense, and/or sell
% copies of the Software, and to permit persons to whom the
% Software is furnished to do so, subject to the following
% conditions:
%
% The above copyright notice and this permission notice shall be
% included in all copies or substantial portions of the Software.
%
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
% OTHER DEALINGS IN THE SOFTWARE.
% hyphenmins:
% typesetting:
% left: 2
% right: 2
% changes:
% - 2014-04-22 - Add few patterns involving `h'
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
% - 2008-03-08 - (last change in ithyph.tex)
% texlive:
% encoding: ascii
% babelname: italian
% legacy_patterns: ithyph.tex
% message: Italian hyphenation patterns
% description: |-
% Hyphenation patterns for Italian in ASCII encoding.
% Compliant with the Recommendation UNI 6461 on hyphenation
% issued by the Italian Standards Institution
% (Ente Nazionale di Unificazione UNI).
% ==========================================
%
% These hyphenation patterns for the Italian language are supposed to comply
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
% or declaration of fitness to any particular purpose is given and any
% liability is disclaimed.
%
\patterns{
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
.anti1
.anti3m2n
.bio1
.ca4p3s
.circu2m1
.contro1
.di2s3cine
.e2x1eu
.fran2k3
.free3
.li3p2sa
.narco1
.opto1
.orto3p2
.para1
.ph2l
.ph2r
.poli3p2
.pre1
.p2s
.re1i2scr
.sha2re3
.tran2s3c
.tran2s3d
.tran2s3l
.tran2s3n
.tran2s3p
.tran2s3r
.tran2s3t
.su2b3lu
.su2b3r
.wa2g3n
.wel2t1
2'2
a1ia
a1ie
a1io
a1iu
a1uo
a1ya
2at.
e1iu
e2w
o1ia
o1ie
o1io
o1iu
1b
2bb
2bc
2bd
2bf
2bm
2bn
2bp
2bs
2bt
2bv
b2l
b2r
2b.
2b'
1c
2cb
2cc
2cd
2cf
2ck
2cm
2cn
2cq
2cs
2ct
2cz
2chh
c2h
2ch.
2ch'.
2ch''.
2chb
ch2r
2chn
c2l
c2r
2c.
2c'
.c2
1d
2db
2dd
2dg
2dl
2dm
2dn
2dp
d2r
2ds
2dt
2dv
2dw
2d.
2d'
.d2
1f
2fb
2fg
2ff
2fn
f2l
f2r
2fs
2ft
2f.
2f'
1g
2gb
2gd
2gf
2gg
g2h
g2l
2gm
g2n
2gp
g2r
2gs
2gt
2gv
2gw
2gz
2gh2t
2g.
2g'
.h2
1h
2hb
2hd
2hh
hi3p2n
h2l
2hm
2hn
2hr
2hv
2h.
2h'
.j2
1j
2j.
2j'
.k2
1k
2kg
2kf
k2h
2kk
k2l
2km
k2r
2ks
2kt
2k.
2k'
1l
2lb
2lc
2ld
2l3f2
2lg
l2h
l2j
2lk
2ll
2lm
2ln
2lp
2lq
2lr
2ls
2lt
2lv
2lw
2lz
2l.
2l'.
2l''
1m
2mb
2mc
2mf
2ml
2mm
2mn
2mp
2mq
2mr
2ms
2mt
2mv
2mw
2m.
2m'
1n
2nb
2nc
2nd
2nf
2ng
2nk
2nl
2nm
2nn
2np
2nq
2nr
2ns
n2s3fer
2nt
2nv
2nz
n2g3n
2nheit
2n.
2n'
1p
2pd
p2h
p2l
2pn
3p2ne
2pp
p2r
2ps
3p2sic
2pt
2pz
2p.
2p'
1q
2qq
2q.
2q'
1r
2rb
2rc
2rd
2rf
r2h
2rg
2rk
2rl
2rm
2rn
2rp
2rq
2rr
2rs
2rt
r2t2s3
2rv
2rx
2rw
2rz
2r.
2r'
1s2
2shm
2sh.
2sh'
2s3s
s4s3m
2s3p2n
2stb
2stc
2std
2stf
2stg
2stm
2stn
2stp
2sts
2stt
2stv
2sz
4s.
4s'.
4s''
.t2
1t
2tb
2tc
2td
2tf
2tg
t2h
2th.
t2l
2tm
2tn
2tp
t2r
t2s
3t2sch
2tt
t2t3s
2tv
2tw
t2z
2tzk
tz2s
2t.
2t'.
2t''
1v
2vc
v2l
v2r
2vv
2v.
2v'.
2v''
1w
w2h
wa2r
2w1y
2w.
2w'
1x
2xb
2xc
2xf
2xh
2xm
2xp
2xt
2xw
2x.
2x'
y1ou
y1i
1z
2zb
2zd
2zl
2zn
2zp
2zt
2zs
2zv
2zz
2z.
2z'.
2z''
.z2
} % Pattern end
-503
View File
@@ -1,503 +0,0 @@
% title: Hyphenation patterns for Portuguese
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Portuguese
% tag: pt
% version: 1.4 2024-07-13
% authors:
% -
% name: Pedro J. de Rezende
% contact: rezende (at) ic.unicamp.br
% -
% name: J. Joao Dias Almeida
% contact: jj (at) di.uminho.pt
% -
% name: Leonardo Araujo
% contact: leolca (at) gmail.com
% -
% name: Aline Benevides
% contact: benevides.aline12 (at) gmail.com
% licence:
% name: BSD 3-clause licence
% url: https://opensource.org/licenses/BSD-3-Clause
% text: >
% Redistribution and use in source and binary forms, with or without
% modification, are permitted provided that the following conditions
% are met:
% * Redistributions of source code must retain the above copyright
% notice, this list of conditions and the following disclaimer.
% * Redistributions in binary form must reproduce the above copyright
% notice, this list of conditions and the following disclaimer in the
% documentation and/or other materials provided with the
% distribution.
% * Neither the name of the University of Campinas, of the University
% of Minho nor the names of its contributors may be used to endorse
% or promote products derived from this software without specific
% prior written permission.
%
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
% POSSIBILITY OF SUCH DAMAGE.
% hyphenmins:
% typesetting:
% left: 2
% right: 3
% changes:
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
% texlive:
% synonyms:
% - portuges
% encoding: ec
% babelname: portuguese
% legacy_patterns: pthyph.tex
% message: Portuguese hyphenation patterns
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
%
\patterns{
1b2l
1b2r
1ba
1be
1bi
1bo
1bu
1bá
1bâ
1bã
1bé
1bí
1bó
1bú
1bê
1bõ
1c2h
1c2l
1c2r
1ca
1ce
1ci
1co
1cu
1cá
1câ
1cã
1cé
1cí
1có
1cú
1cê
1cõ
1ça
1çe
1çi
1ço
1çu
1çá
1çâ
1çã
1çé
1çí
1çó
1çú
1çê
1çõ
1d2l
1d2r
1da
1de
1di
1do
1du
1dá
1dâ
1dã
1dé
1dí
1dó
1dú
1dê
1dõ
1f2l
1f2r
1fa
1fe
1fi
1fo
1fu
1fá
1fâ
1fã
1fé
1fí
1fó
1fú
1fê
1fõ
1g2l
1g2r
1ga
1ge
1gi
1go
1gu
1gu4a
1gu4e
1gu4i
1gu4o
1gá
1gâ
1gã
1gé
1gí
1gó
1gú
1gê
1gõ
1ja
1je
1ji
1jo
1ju
1já
1jâ
1jã
1jé
1jí
1jó
1jú
1jê
1jõ
1k2l
1k2r
1ka
1ke
1ki
1ko
1ku
1ká
1kâ
1kã
1ké
1kí
1kó
1kú
1kê
1kõ
1l2h
1la
1le
1li
1lo
1lu
1lá
1lâ
1lã
1lé
1lí
1ló
1lú
1lê
1lõ
1ma
1me
1mi
1mo
1mu
1má
1mâ
1mã
1mé
1mí
1mó
1mú
1mê
1mõ
1n2h
1na
1ne
1ni
1no
1nu
1ná
1nâ
1nã
1né
1ní
1nó
1nú
1nê
1nõ
1p2l
1p2r
1pa
1pe
1pi
1po
1pu
1pá
1pâ
1pã
1pé
1pí
1pó
1pú
1pê
1põ
1qu4a
1qu4e
1qu4i
1qu4o
1ra
1re
1ri
1ro
1ru
1rá
1râ
1rã
1ré
1rí
1ró
1rú
1rê
1rõ
1sa
1se
1si
1so
1su
1sá
1sâ
1sã
1sé
1sí
1só
1sú
1sê
1sõ
1t2l
1t2r
1ta
1te
1ti
1to
1tu
1tá
1tâ
1tã
1té
1tí
1tó
1tú
1tê
1tõ
1v2l
1v2r
1va
1ve
1vi
1vo
1vu
1vá
1vâ
1vã
1vé
1ví
1vó
1vú
1vê
1võ
1w2l
1w2r
1xa
1xe
1xi
1xo
1xu
1xá
1xâ
1xã
1xé
1xí
1xó
1xú
1xê
1xõ
1za
1ze
1zi
1zo
1zu
1zá
1zâ
1zã
1zé
1zí
1zó
1zú
1zê
1zõ
a3a
a3e
a3o
c3c
e3a
e3e
e3o
i3a
i3e
i3i
i3o
i3â
i3ê
i3ô
o3a
o3e
o3o
r3r
s3s
u3a
u3e
u3o
u3u
.p2si
.p2sí
.g2no
.g2nó
.g2nô
t2c
1p2neu
.t2m
.p2t
su2b3r
su2b3l
.su3b4li
a1ir.
u1ir.
1qu
1vô
1lô
1cô
1gô
1bô
1tô
1rô
1pô
a1é
a1í
a1ó
a1ú
e1á
e1â
e1ã
e1é
e1ê
e1í
e1ó
é1o
e1ú
i1á
i1ã
í1a
i1é
i1í
i1ó
í1o
i1u
i1ú
o1á
o1é
o1í
o1ó
u1á
u1ã
u1â
u1í
ú1o
1qu2á
1qu2â
1qu2í
1gu2í
a1ind
a1i1nh
e1imp
e1inc
e1inf
e1ing
e1ins
e1int
e1inv
u1iz.
a1iz.
4a.
4e.
4o.
1gu2á
1gu2ã
1qu2ã
.m2n
c2za
.s2
1p2seu1d
1dô
1fô
1mô
1nô
1sô
1zô
tu1i
tu2it
tu2id
bu1i
nu1i
o1in
u1in
su1i
í1e
ju1i
fu1i
du1i
do1im
au1i
u1i1ç
u1ê
1gu2ê
1qu2ê
1çô
u1é
1gu2é
1qu2é
1xô
a1â
a1ã
a1ô
e1ô
.ne4o
o1ã
o1ê
o1im
o2i1na
pro1i1b
co2ima
.t2
1-
}
\hyphenation{% Do NOT make any alterations to this list! --- PdR
hard-ware
soft-ware
}
-17
View File
@@ -26,12 +26,7 @@ chatroom
checkbox
checklist
childcare
clamour
clickable
colourful
colourisation
colourise
colourless
configurable
copyedit
coronavirus
@@ -59,7 +54,6 @@ ecommerce
email
emoji
emoticon
endeavour
esports
facebook
favicon
@@ -77,7 +71,6 @@ geolocation
gigabit
gigabyte
github
glamour
globalization
google
groupthink
@@ -87,9 +80,6 @@ hashtag
healthcare
homepage
homeschool
honour
honourable
honourably
hotspot
hyperlink
hypertext
@@ -116,7 +106,6 @@ logout
lowercase
makeover
malware
manoeuvrable
megabyte
megapixel
meme
@@ -138,7 +127,6 @@ onboard
online
onscreen
outsource
paediatric
paralegal
passcode
petabyte
@@ -151,13 +139,10 @@ podcast
popup
prequel
printout
prioritise
prioritization
prioritize
privatise
ransomware
recyclable
rigour
ringtone
roadmap
rollout
@@ -186,7 +171,6 @@ subfolder
sunscreen
superfood
superuser
synthesise
tagline
takeout
techie
@@ -204,7 +188,6 @@ touchpad
touchscreen
trackball
trackpad
travelogue
upcycle
upload
uppercase
-214
View File
@@ -1,214 +0,0 @@
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One word per line: the base form, a TAB, then its declension, from which
# crates/proof/src/ru/extra.rs makes every form:
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
# only when written with a capital.
# Commerce, marketing and the web
маркетплейс m
селлер m
фулфилмент m
ритейл m
ритейлер m
дистрибьютор m
дропшиппинг m
мерчандайзер m
эквайринг m
промокод m
кешбэк m
кэшбэк m
лендинг m
лид m
тренд m
бренд m
брендбук m
ребрендинг m
таргетинг m
геотаргетинг m
ретаргетинг m
таргетолог m
маркетолог m
копирайтер m
рерайтер m
контент m
блогер m
инфлюенсер m
хештег m
хэштег m
репост m
лайк m
подкаст m
стриминг m
вебинар m
чат m
бот m
чат-бот m
стартап m
франчайзинг m
аутсорсинг m
аутстаффинг m
коворкинг m
дедлайн m
фидбэк m
фидбек m
кейс m
скриншот m
скринкаст m
баннер m
виджет m
дашборд m
сервис m
сайт m
скан m
токен m
аккаунт m
логин m
пароль m
смартфон m
гаджет m
планшет m
ноутбук m
апгрейд m
апдейт m
бэкап m
бэкенд m
фронтенд m
датасет m
промпт m
интерфейс m
хостинг m
домен m
сниппет m
трафик m
юзер m
стикер m
чекбокс m
инпут m
плейсхолдер m
тултип m
гипсокартон m
пеноплекс m
праймер m
колер m
колорант m
Яндекс m
Битрикс m
Вебвизор m
Дзен m
инфографика f
аналитика f
оплата f
предоплата f
постоплата f
доплата f
переплата f
недоплата f
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
интеграция fia
пагинация fia
госрегистрация fia
геолокация fia
монетизация fia
кастомизация fia
персонализация fia
сегментация fia
верификация fia
валидация fia
авторизация fia
аутентификация fia
оптимизация fia
визуализация fia
соцсеть fs
согласие nie
домовладение nie
рождение nie
паллетирование nie
неисполнение nie
непредоставление nie
бонусный adj
маркетинговый adj
мотивационный adj
гипоаллергенный adj
тиксотропный adj
сополимерный adj
покупочный adj
таргетированный adj
брендированный adj
кастомный adj
онлайновый adj
офлайновый adj
колеруемый adj
колерованный adj
заколерованный adj
газоиспользующий adj
колеровать ovat
заколеровать ovat
администрировать ovat
модерировать ovat
отмодерировать ovat
таргетировать ovat
монетизировать ovat
брендировать ovat
кастомизировать ovat
детектировать ovat
верифицировать ovat
валидировать ovat
генерировать ovat
сгенерировать ovat
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
оценивание nie
медучреждение nie
юрлицо no
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
детекция fia
досудебный adj
категорийный adj
оркестратор m
оффер m
трекер m
бриф m
гайд m
монтажёр m
тенант m
мерчант m
амбассадор m
Роскомнадзор m
Директ m
Маркет m
вовлечённость fs
вовлеченность fs
укрывистость fs
# Words that don't decline
онлайн
офлайн
оффлайн
медиа
демо
франчайзи
киноа
смс
пн
вт
ср
чт
пт
сб
вс
+1 -74
View File
@@ -17,13 +17,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
[people.lokinmodar]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -58,80 +51,14 @@ sync_github_name = false
[people.henno]
sync_github_name = true
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
display_name = "Ufuk Efe"
sync_github_name = true
[people.GhostMitra]
sync_github_name = true
[people.DevCoderMax]
real_name = "Gleyson do Nascimento Carvalho"
display_name = "Gleyson Carvalho"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.edurbs]
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.manucarcor]
real_name = "Manuel Carmona"
display_name = "manucarcor"
[people.odedigo]
[people.neupaneprashant]
real_name = "Prashant Neupane"
[people.cyril265]
display_name = "cyril265"
[people.webdevtodayjason]
display_name = "Jason Brashear"
sync_github_name = false
[people.lupant]
sync_github_name = true
[people.Nael-Nathanael]
[people.Losminek]
[people.Nghiathan13]
[people.leoncral]
sync_github_name = true
[people.ms-youssef]
sync_github_name = true
[people.finwarman]
real_name = "Fin Warman"
[people.albayazid]
real_name = "Sazid Al Bayazid"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
[people.tlomts-dev]
display_name = "The Legend Of MTS Dev"
[people.dotCooCoo]
real_name = "Robert Lee"
[people.rbr48]
real_name = "M S Rana"
display_name = "Izhaan Intellect"
[people.holmbom]
real_name = "Erik Holmbom"
display_name = "Erik Holmbom"
sync_github_name = false
[people.andrejskvorc]
real_name = "Andrej Škvorc"
display_name = "Andrej Škvorc"
sync_github_name = false
-4
View File
@@ -6,11 +6,7 @@ use std::path::{Path, PathBuf};
/// The app's settings folder (preferences, logs, key files): macOS
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
pub fn settings_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(path));
}
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
} else if cfg!(windows) {
-26
View File
@@ -37,26 +37,6 @@ pub fn has_rtl(text: &str) -> bool {
text.chars().any(is_rtl_script)
}
/// Does the first strong directional character in `text` belong to a right-to-left script?
///
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped.
pub fn first_strong_is_rtl(text: &str) -> bool {
for c in text.chars() {
if c == '\u{200F}' {
return true;
}
if c == '\u{200E}' {
return false;
}
if c.is_alphabetic() {
return is_rtl_script(c);
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -76,11 +56,5 @@ mod tests {
assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word");
assert!("کِتاب".chars().all(is_word_char), "so are harakat");
assert!(!is_word_char(' ') && !is_word_char('،'));
assert!(first_strong_is_rtl("سلام"));
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
assert!(first_strong_is_rtl(" 123. سلام"));
assert!(!first_strong_is_rtl("Hello سلام"));
assert!(!first_strong_is_rtl("123 Hello"));
assert!(!first_strong_is_rtl(""));
}
}
-520
View File
@@ -1,520 +0,0 @@
//! Language-aware sorting of text: a small, pure-Rust collation.
//!
//! Every letter gets a *primary* weight (its place in the alphabet) and a *secondary* one (its
//! diacritic). Strings compare by primary weights first, then by secondary ones, so `é` sorts
//! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
//! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
//! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
//! first letter. The per-language tables follow the published alphabets of those languages.
//! Text without diacritics sorts exactly as a case-insensitive code-point compare.
use std::cmp::Ordering;
/// The diacritic of a precomposed lowercase Latin letter: `(base letters, mark)`. Marks: 1 acute,
/// 2 grave, 3 circumflex, 4 diaeresis, 5 tilde, 6 ring, 7 caron, 8 cedilla, 9 ogonek, 10 dot,
/// 11 stroke, 12 breve, 13 macron, 14 double acute, 15 comma below, 16 ligature, 17 dotless,
/// 18 eth.
const DECOMP: &[(char, &str, u16)] = &[
('à', "a", 2),
('á', "a", 1),
('â', "a", 3),
('ã', "a", 5),
('ä', "a", 4),
('å', "a", 6),
('ā', "a", 13),
('ă', "a", 12),
('ą', "a", 9),
('æ', "ae", 16),
('ç', "c", 8),
('ć', "c", 1),
('ĉ', "c", 3),
('ċ', "c", 10),
('č', "c", 7),
('ď', "d", 7),
('đ', "d", 11),
('ð', "d", 18),
('è', "e", 2),
('é', "e", 1),
('ê', "e", 3),
('ë', "e", 4),
('ē', "e", 13),
('ĕ', "e", 12),
('ė', "e", 10),
('ę', "e", 9),
('ě', "e", 7),
('ĝ', "g", 3),
('ğ', "g", 12),
('ġ', "g", 10),
('ģ', "g", 8),
('ĥ', "h", 3),
('ħ', "h", 11),
('ì', "i", 2),
('í', "i", 1),
('î', "i", 3),
('ï', "i", 4),
('ĩ', "i", 5),
('ī', "i", 13),
('ĭ', "i", 12),
('į', "i", 9),
('ı', "i", 17),
('ij', "ij", 16),
('ĵ', "j", 3),
('ķ', "k", 8),
('ĺ', "l", 1),
('ļ', "l", 8),
('ľ', "l", 7),
('ŀ', "l", 10),
('ł', "l", 11),
('ñ', "n", 5),
('ń', "n", 1),
('ņ', "n", 8),
('ň', "n", 7),
('ò', "o", 2),
('ó', "o", 1),
('ô', "o", 3),
('õ', "o", 5),
('ö', "o", 4),
('ø', "o", 11),
('ō', "o", 13),
('ŏ', "o", 12),
('ő', "o", 14),
('œ', "oe", 16),
('ŕ', "r", 1),
('ŗ', "r", 8),
('ř', "r", 7),
('ś', "s", 1),
('ŝ', "s", 3),
('ş', "s", 8),
('š', "s", 7),
('ș', "s", 15),
('ß', "ss", 16),
('ţ', "t", 8),
('ť', "t", 7),
('ŧ', "t", 11),
('ț', "t", 15),
('ù', "u", 2),
('ú', "u", 1),
('û', "u", 3),
('ü', "u", 4),
('ũ', "u", 5),
('ū', "u", 13),
('ŭ', "u", 12),
('ů', "u", 6),
('ű', "u", 14),
('ų', "u", 9),
('ŵ', "w", 3),
('ý', "y", 1),
('ÿ', "y", 4),
('ŷ', "y", 3),
('ź', "z", 1),
('ż', "z", 10),
('ž', "z", 7),
];
/// A letter (or letter group) a language sorts as a letter of its own: right after `after`
/// (`rank` places after it), with secondary weight `sec` among letters of the same place.
struct Tailor {
seq: &'static str,
after: char,
rank: u32,
sec: u16,
}
const fn t(seq: &'static str, after: char, rank: u32) -> Tailor {
Tailor { seq, after, rank, sec: 0 }
}
const fn ts(seq: &'static str, after: char, rank: u32, sec: u16) -> Tailor {
Tailor { seq, after, rank, sec }
}
const PL: &[Tailor] =
&[t("ą", 'a', 1), t("ć", 'c', 1), t("ę", 'e', 1), t("ł", 'l', 1), t("ń", 'n', 1), t("ó", 'o', 1), t("ś", 's', 1), t("ź", 'z', 1), t("ż", 'z', 2)];
const CS: &[Tailor] = &[t("č", 'c', 1), t("ch", 'h', 1), t("ř", 'r', 1), t("š", 's', 1), t("ž", 'z', 1)];
const SK: &[Tailor] = &[t("ä", 'a', 1), t("č", 'c', 1), t("ch", 'h', 1), t("ô", 'o', 1), t("š", 's', 1), t("ž", 'z', 1)];
const HU: &[Tailor] = &[
t("cs", 'c', 1),
t("dz", 'd', 1),
t("dzs", 'd', 2),
t("gy", 'g', 1),
t("ly", 'l', 1),
t("ny", 'n', 1),
t("ö", 'o', 1),
ts("ő", 'o', 1, 14),
t("sz", 's', 1),
t("ty", 't', 1),
t("ü", 'u', 1),
ts("ű", 'u', 1, 14),
t("zs", 'z', 1),
];
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
const HR: &[Tailor] =
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const RO: &[Tailor] = &[t("ă", 'a', 1), t("â", 'a', 2), t("î", 'i', 1), t("ș", 's', 1), ts("ş", 's', 1, 8), t("ț", 't', 1), ts("ţ", 't', 1, 8)];
const ET: &[Tailor] = &[t("š", 's', 1), t("z", 's', 2), t("ž", 's', 3), t("õ", 'w', 1), t("ä", 'w', 2), t("ö", 'w', 3), t("ü", 'w', 4)];
const LT: &[Tailor] = &[t("č", 'c', 1), t("š", 's', 1), t("ž", 'z', 1)];
const LV: &[Tailor] = &[t("č", 'c', 1), t("ģ", 'g', 1), t("ķ", 'k', 1), t("ļ", 'l', 1), t("ņ", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
const IS: &[Tailor] = &[
t("á", 'a', 1),
t("ð", 'd', 1),
t("é", 'e', 1),
t("í", 'i', 1),
t("ó", 'o', 1),
t("ú", 'u', 1),
t("ý", 'y', 1),
t("þ", 'z', 1),
t("æ", 'z', 2),
t("ö", 'z', 3),
];
/// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
pub fn language(lang: Option<&str>) -> String {
lang.and_then(|l| l.split(['-', '_']).next()).unwrap_or("").trim().to_ascii_lowercase()
}
fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
match language(lang).as_str() {
"pl" => PL,
"cs" => CS,
"sk" => SK,
"hu" => HU,
"sv" | "fi" => SV,
"da" | "nb" | "nn" | "no" => DA,
"es" | "gl" => ES,
"tr" | "az" => TR,
"hr" | "bs" | "sr" => HR,
"ro" => RO,
"et" => ET,
"lt" => LT,
"lv" => LV,
"is" => IS,
"sl" => LT,
_ => &[],
}
}
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
const COMPOSE: &[(char, char, char)] = &[
('a', '\u{300}', 'à'),
('a', '\u{301}', 'á'),
('a', '\u{302}', 'â'),
('a', '\u{303}', 'ã'),
('a', '\u{304}', 'ā'),
('a', '\u{306}', 'ă'),
('a', '\u{307}', 'ȧ'),
('a', '\u{308}', 'ä'),
('a', '\u{30a}', 'å'),
('a', '\u{30c}', 'ǎ'),
('a', '\u{328}', 'ą'),
('b', '\u{307}', 'ḃ'),
('c', '\u{301}', 'ć'),
('c', '\u{302}', 'ĉ'),
('c', '\u{307}', 'ċ'),
('c', '\u{30c}', 'č'),
('c', '\u{327}', 'ç'),
('d', '\u{307}', 'ḋ'),
('d', '\u{30c}', 'ď'),
('d', '\u{327}', 'ḑ'),
('e', '\u{300}', 'è'),
('e', '\u{301}', 'é'),
('e', '\u{302}', 'ê'),
('e', '\u{303}', 'ẽ'),
('e', '\u{304}', 'ē'),
('e', '\u{306}', 'ĕ'),
('e', '\u{307}', 'ė'),
('e', '\u{308}', 'ë'),
('e', '\u{30c}', 'ě'),
('e', '\u{327}', 'ȩ'),
('e', '\u{328}', 'ę'),
('f', '\u{307}', 'ḟ'),
('g', '\u{301}', 'ǵ'),
('g', '\u{302}', 'ĝ'),
('g', '\u{304}', 'ḡ'),
('g', '\u{306}', 'ğ'),
('g', '\u{307}', 'ġ'),
('g', '\u{30c}', 'ǧ'),
('g', '\u{327}', 'ģ'),
('h', '\u{302}', 'ĥ'),
('h', '\u{307}', 'ḣ'),
('h', '\u{308}', 'ḧ'),
('h', '\u{30c}', 'ȟ'),
('h', '\u{327}', 'ḩ'),
('i', '\u{300}', 'ì'),
('i', '\u{301}', 'í'),
('i', '\u{302}', 'î'),
('i', '\u{303}', 'ĩ'),
('i', '\u{304}', 'ī'),
('i', '\u{306}', 'ĭ'),
('i', '\u{308}', 'ï'),
('i', '\u{30c}', 'ǐ'),
('i', '\u{328}', 'į'),
('j', '\u{302}', 'ĵ'),
('j', '\u{30c}', 'ǰ'),
('k', '\u{301}', 'ḱ'),
('k', '\u{30c}', 'ǩ'),
('k', '\u{327}', 'ķ'),
('l', '\u{301}', 'ĺ'),
('l', '\u{30c}', 'ľ'),
('l', '\u{327}', 'ļ'),
('m', '\u{301}', 'ḿ'),
('m', '\u{307}', 'ṁ'),
('n', '\u{300}', 'ǹ'),
('n', '\u{301}', 'ń'),
('n', '\u{303}', 'ñ'),
('n', '\u{307}', 'ṅ'),
('n', '\u{30c}', 'ň'),
('n', '\u{327}', 'ņ'),
('o', '\u{300}', 'ò'),
('o', '\u{301}', 'ó'),
('o', '\u{302}', 'ô'),
('o', '\u{303}', 'õ'),
('o', '\u{304}', 'ō'),
('o', '\u{306}', 'ŏ'),
('o', '\u{307}', 'ȯ'),
('o', '\u{308}', 'ö'),
('o', '\u{30b}', 'ő'),
('o', '\u{30c}', 'ǒ'),
('o', '\u{328}', 'ǫ'),
('p', '\u{301}', 'ṕ'),
('p', '\u{307}', 'ṗ'),
('r', '\u{301}', 'ŕ'),
('r', '\u{307}', 'ṙ'),
('r', '\u{30c}', 'ř'),
('r', '\u{327}', 'ŗ'),
('s', '\u{301}', 'ś'),
('s', '\u{302}', 'ŝ'),
('s', '\u{307}', 'ṡ'),
('s', '\u{30c}', 'š'),
('s', '\u{326}', 'ș'),
('s', '\u{327}', 'ş'),
('t', '\u{307}', 'ṫ'),
('t', '\u{308}', 'ẗ'),
('t', '\u{30c}', 'ť'),
('t', '\u{326}', 'ț'),
('t', '\u{327}', 'ţ'),
('u', '\u{300}', 'ù'),
('u', '\u{301}', 'ú'),
('u', '\u{302}', 'û'),
('u', '\u{303}', 'ũ'),
('u', '\u{304}', 'ū'),
('u', '\u{306}', 'ŭ'),
('u', '\u{308}', 'ü'),
('u', '\u{30a}', 'ů'),
('u', '\u{30b}', 'ű'),
('u', '\u{30c}', 'ǔ'),
('u', '\u{328}', 'ų'),
('v', '\u{303}', 'ṽ'),
('w', '\u{300}', 'ẁ'),
('w', '\u{301}', 'ẃ'),
('w', '\u{302}', 'ŵ'),
('w', '\u{307}', 'ẇ'),
('w', '\u{308}', 'ẅ'),
('w', '\u{30a}', 'ẘ'),
('x', '\u{307}', 'ẋ'),
('x', '\u{308}', 'ẍ'),
('y', '\u{300}', 'ỳ'),
('y', '\u{301}', 'ý'),
('y', '\u{302}', 'ŷ'),
('y', '\u{303}', 'ỹ'),
('y', '\u{304}', 'ȳ'),
('y', '\u{307}', 'ẏ'),
('y', '\u{308}', 'ÿ'),
('y', '\u{30a}', 'ẙ'),
('z', '\u{301}', 'ź'),
('z', '\u{302}', 'ẑ'),
('z', '\u{307}', 'ż'),
('z', '\u{30c}', 'ž'),
];
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
fn lower_composed(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.to_lowercase().chars() {
if let Some(prev) = out.chars().next_back()
&& let Some((_, _, composed)) = COMPOSE.iter().find(|(b, m, _)| *b == prev && *m == c)
{
out.pop();
out.push(*composed);
} else {
out.push(c);
}
}
out
}
fn weight(c: char, rank: u32) -> u32 {
((c as u32) << 4).saturating_add(rank)
}
/// One collation element: primary weight, secondary weight, the text it covers.
struct Elem<'a> {
primary: Vec<u32>,
sec: u16,
text: &'a str,
/// Set when the text is a letter of its own in the language (shown as is in headings).
own: bool,
}
/// The next collation element of `rest` (lowercase), and its length in bytes.
fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
let text = rest.get(..r.seq.len())?;
return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
}
let c = rest.chars().next()?;
let n = c.len_utf8();
let text = rest.get(..n)?;
if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
}
// A combining mark adds a secondary difference to the letter before it.
if ('\u{300}'..='\u{36f}').contains(&c) {
return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
}
Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
}
/// A sort key: compare keys to sort strings in a language's order. Equal keys mean the strings
/// differ at most in letter case.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct SortKey {
primary: Vec<u32>,
secondary: Vec<u16>,
text: String,
}
/// The sort key of `s` for language `lang` (a BCP 47 tag such as `pl-PL`; `None` sorts like
/// English).
pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
let text = lower_composed(s);
let rules = tailoring(lang);
let mut key = SortKey::default();
let mut i = 0;
while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
let Some((e, n)) = next_elem(rest, rules) else { break };
i += n.max(1);
if e.primary.is_empty() {
if let Some(last) = key.secondary.last_mut() {
*last = last.saturating_add(e.sec);
}
continue;
}
for (k, w) in e.primary.iter().enumerate() {
key.primary.push(*w);
key.secondary.push(if k == 0 { e.sec } else { 0 });
}
}
key.text = text;
key
}
/// Compare two strings in the order of language `lang`, ignoring case.
pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
sort_key(a, lang).cmp(&sort_key(b, lang))
}
/// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
let low = lower_composed(s.trim_start());
let (e, _) = next_elem(&low, tailoring(lang))?;
let shown = if e.own || e.sec == 0 {
e.text.to_string()
} else {
DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
};
let mut cs = shown.chars();
let first = cs.next()?;
Some(first.to_uppercase().chain(cs).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sorted(words: &[&str], lang: &str) -> Vec<String> {
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
v.sort_by(|a, b| collate(a, b, Some(lang)));
v
}
#[test]
fn polish_letters_follow_their_base_letter() {
assert_eq!(sorted(&["Zakopane", "Łódź", "Kraków"], "pl-PL"), ["Kraków", "Łódź", "Zakopane"]);
assert_eq!(sorted(&["zebra", "ćma", "dom"], "pl-PL"), ["ćma", "dom", "zebra"]);
assert_eq!(sorted(&["żaba", "źle", "zebra", "ząb", "lato", "łapa", "mama"], "pl"), ["lato", "łapa", "mama", "ząb", "zebra", "źle", "żaba"]);
assert_eq!(sorted(&["b", "ą", "a"], "pl"), ["a", "ą", "b"]);
// Every Polish letter is a letter of its own, after its base letter (review of #844).
assert_eq!(sorted(&["Malbork", "Łódź", "Lublin"], "pl"), ["Lublin", "Łódź", "Malbork"]);
assert_eq!(sorted(&["ósmy", "owca", "pies"], "pl"), ["owca", "ósmy", "pies"]);
assert_eq!(sorted(&["ściana", "sum", "tak"], "pl"), ["sum", "ściana", "tak"]);
assert_eq!(sorted(&["ęsi", "ezop", "fala"], "pl"), ["ezop", "ęsi", "fala"]);
assert_eq!(sorted(&["ńa", "nz", "oko"], "pl"), ["nz", "ńa", "oko"]);
assert_eq!(heading_letter("ósmy", Some("pl")).as_deref(), Some("Ó"));
assert_eq!(heading_letter("ńa", Some("pl")).as_deref(), Some("Ń"));
// Decomposed letters (`z` + combining dot above, as macOS file names write `ż`) sort like
// the composed ones.
assert_eq!(collate("z\u{307}aba", "żaba", Some("pl-PL")), Ordering::Equal);
assert_eq!(collate("z\u{307}aba", "zebra", Some("pl-PL")), Ordering::Greater);
assert_eq!(heading_letter("Z\u{307}aba", Some("pl")).as_deref(), Some("Ż"));
assert_eq!(collate("a\u{328}", "ą", Some("pl")), Ordering::Equal);
}
#[test]
fn swedish_puts_a_ring_and_umlauts_after_z() {
assert_eq!(sorted(&["ödla", "zebra", "äpple", "åka", "apa"], "sv-SE"), ["apa", "zebra", "åka", "äpple", "ödla"]);
}
#[test]
fn german_sorts_umlauts_with_their_base_letter() {
assert_eq!(sorted(&["Zug", "Äpfel", "Apfel", "Bach"], "de-DE"), ["Apfel", "Äpfel", "Bach", "Zug"]);
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
}
#[test]
fn spanish_n_tilde_after_n() {
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
}
#[test]
fn czech_slovak_hungarian_alphabets() {
assert_eq!(sorted(&["chata", "hrad", "ihla", "cesta", "čaj", "dům"], "cs-CZ"), ["cesta", "čaj", "dům", "hrad", "chata", "ihla"]);
assert_eq!(sorted(&["ôsmy", "oko", "pes", "ä", "b"], "sk"), ["ä", "b", "oko", "ôsmy", "pes"]);
assert_eq!(sorted(&["csak", "cukor", "dob", "szó", "sűrű", "tál"], "hu-HU"), ["cukor", "csak", "dob", "sűrű", "szó", "tál"]);
// ö and ő are one letter after o, told apart by the accent only.
assert_eq!(sorted(&["ötös", "őr", "pálya", "olló", "ör"], "hu"), ["olló", "ör", "őr", "ötös", "pálya"]);
}
#[test]
fn english_keeps_case_insensitive_order_and_folds_accents() {
assert_eq!(sorted(&["rex", "résumé", "resume", "Apple", "banana"], "en-US"), ["Apple", "banana", "resume", "résumé", "rex"]);
assert_eq!(collate("Łódź", "Lodz", None), Ordering::Greater);
assert_eq!(collate("Łódź", "Lodzia", None), Ordering::Less);
assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
}
#[test]
fn heading_letters() {
assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
assert_eq!(heading_letter("Äpfel", Some("de")).as_deref(), Some("A"));
assert_eq!(heading_letter("Äpple", Some("sv")).as_deref(), Some("Ä"));
assert_eq!(heading_letter("", Some("sv")), None);
}
#[test]
fn hostile_input_never_panics() {
for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
let _ = sort_key(s, l);
let _ = heading_letter(s, l);
}
}
}
}
+1 -32
View File
@@ -1,5 +1,5 @@
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
//! warp, a rounded rectangle's corner, and a connector's ends.
//! warp, and a connector's ends.
//!
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
@@ -34,10 +34,6 @@ pub struct ShapeExtra {
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
pub corner: Option<u32>,
}
fn is_zero(v: &u32) -> bool {
@@ -53,19 +49,6 @@ impl ShapeExtra {
}
}
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
pub const MAX_CORNER: u32 = 50_000;
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
pub const DEFAULT_CORNER: f32 = 0.16;
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
/// [`ShapeExtra::corner`]).
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
}
/// One glued end of a connector: the drawing id of the shape and its connection site index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -100,8 +83,6 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
}
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
// The corners, the legs' midpoints and the hypotenuse's.
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
}
@@ -256,18 +237,6 @@ mod tests {
}
}
#[test]
fn corner_radius_follows_the_shorter_side() {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
-6
View File
@@ -490,12 +490,6 @@ impl Document {
out
}
/// How many content controls allow form filling (`w:lock` content editing) across every
/// story; 0 means a `w:edit="forms"` document has nothing left editable.
pub fn fillable_controls(&self) -> usize {
self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum()
}
/// The innermost content control holding `p`.
pub fn control_at(&self, p: &Pos) -> Option<ControlRange> {
let para = self.para_at(p)?;
+23 -104
View File
@@ -23,27 +23,9 @@ pub struct Fragment {
impl Fragment {
pub fn plain_text(&self) -> String {
self.to_document().plain_text(crate::StoryRef::Body)
}
/// The fragment as the body of a new document (default styles).
pub fn to_document(&self) -> Document {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d
}
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
/// system clipboard as MathML (#816).
pub fn has_equation(&self) -> bool {
let mut found = false;
for b in &self.blocks {
each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. })));
}
found
}
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
/// (Wingdings…) as their Unicode equivalents.
pub fn clipboard_text(&self) -> String {
self.to_document().shown_text(crate::StoryRef::Body)
d.plain_text(crate::StoryRef::Body)
}
pub fn from_text(s: &str) -> Fragment {
let lines: Vec<&str> = s.split('\n').collect();
@@ -63,46 +45,37 @@ const MAX_BOX_DEPTH: usize = 8;
/// Most text box stories one paste may create (hostile documents can fan out).
const MAX_PASTED_BOXES: usize = 4096;
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
// Most blocks are paragraphs, which need no stack.
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
// Last first, so they come off the stack in document order.
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
/// Call `f` on every paragraph of a block (tables descended, depth-limited).
pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
for cb in &cell.blocks {
each_para(cb, depth + 1, f);
}
}
}
Block::Table(_) => {}
}
}
/// [`each_para`], changing the paragraphs.
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut));
pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
for cb in &mut cell.blocks {
each_para_mut(Arc::make_mut(cb), depth + 1, f);
}
}
}
Block::Table(_) => {}
}
}
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job.
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
let is = |s: &Option<String>| s.as_deref() == Some(id);
match b {
@@ -317,8 +290,7 @@ impl Document {
self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect()
}
/// Delete `a..b`. Returns the position where the deletion collapsed to. On error the document
/// is unchanged: both ends are checked before anything is deleted.
/// Delete `a..b`. Returns the position where the deletion collapsed to.
pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result<Pos> {
let (a, b) = order(a, b);
if a == b {
@@ -327,14 +299,6 @@ impl Document {
if a.story != b.story {
return Err(DocError::Invalid("range spans stories".into()));
}
// Check both ends before the first edit, so an error never leaves the document half-deleted.
for p in [&a, &b] {
match self.para(p.story, &p.path) {
Some(para) if para.clamp(p.off) == p.off => {}
Some(_) => return Err(DocError::BadOffset(p.off)),
None => return Err(DocError::BadPath(p.path.to_string())),
}
}
// Content control markers whose partner is outside the range stay (the control is
// emptied, not broken).
let kept = self.kept_control_markers(&a, &b);
@@ -407,27 +371,6 @@ impl Document {
}
}
}
// From the very start of a top-level table to a top-level paragraph (Select All in a
// document that starts with a table, #1198): the tables are covered whole and go too,
// and the paragraphs left between join.
if a.path.depth() > 0 && b.path.depth() == 0 && a.off == 0 {
let ta = a.path.0.first().copied().unwrap_or(0) as usize;
let first = self.para_paths(a.story).into_iter().find(|q| q.0.first().is_some_and(|t| *t as usize == ta));
if first.as_ref() == Some(&a.path) {
let ib = b.path.last();
let bl = self.container_mut(a.story, &b.path)?;
let mut removed = 0;
for i in (ta..ib).rev() {
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
bl.remove(i);
removed += 1;
}
}
let at = Pos { story: a.story, path: Path::top(ta), off: 0 };
let end = Pos { story: a.story, path: Path::top(ib.saturating_sub(removed)), off: 0 };
return self.delete_range(&at, &end);
}
}
Ok(a)
}
@@ -593,7 +536,7 @@ impl Document {
let body = crate::TextBody { next: None, ..src.body };
*budget -= 1;
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
let id = self.add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect());
if let Some(p) = self.parts.get_mut(&id) {
p.body = body;
}
@@ -705,30 +648,6 @@ mod tests {
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
}
#[test]
fn delete_range_with_bad_end_leaves_document_unchanged() {
let mut d = doc(&["one", "two", "three"]);
let before = d.plain_text(StoryRef::Body);
// Missing paragraph as the second end.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset past the end of the second paragraph.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset inside a multi-byte character.
let mut d = doc(&["abc", "\u{e9}x"]);
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
// Range entering a table with a bad offset at its start: the cell text stays.
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
let before = d.plain_text(StoryRef::Body);
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
}
#[test]
fn delete_multi() {
let mut d = doc(&["one", "two", "three"]);
+1 -351
View File
@@ -1,7 +1,4 @@
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
//! (10000, RTF `\mac`).
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
@@ -17,350 +14,3 @@ pub fn cp1252(b: u8) -> char {
b as char
}
}
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP874: [u16; 128] = [
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
0xFFFD, 0xFFFD,
];
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1250: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
0x0163, 0x02D9,
];
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1251: [u16; 128] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
0x044E, 0x044F,
];
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1253: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
0x03CE, 0xFFFD,
];
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1254: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
0x015F, 0x00FF,
];
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1255: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD,
];
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1256: [u16; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1257: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
0x017E, 0x02D9,
];
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1258: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
0x20AB, 0x00FF,
];
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
/// 0xDB (Mac OS 8.5 and later).
const MAC_ROMAN: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
0x02DB, 0x02C7,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// Mac OS Roman, RTF's `\mac` character set.
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
1250 => &CP1250,
1251 => &CP1251,
1253 => &CP1253,
1254 => &CP1254,
1255 => &CP1255,
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
_ => return cp1252(b),
};
if b < 0x80 {
return b as char;
}
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
}
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
pub fn is_byte_code_page(code_page: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
}
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250, 77 Mac → 10000, 254 PC → 437… `None` for the ANSI and default charsets (the
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
77 => MAC_ROMAN_CODE_PAGE,
254 => 437,
161 => 1253,
162 => 1254,
163 => 1258,
177 => 1255,
178 => 1256,
186 => 1257,
204 => 1251,
222 => 874,
238 => 1250,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_byte_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
assert_eq!(decode(9999, &[0xE9]), "é");
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
assert_eq!(charset_code_page(77), Some(10000));
assert_eq!(charset_code_page(254), Some(437));
assert_eq!(charset_code_page(204), Some(1251));
assert_eq!(charset_code_page(0), None);
for cp in SINGLE_BYTE_CODE_PAGES {
for b in 0..=255u8 {
let _ = windows_byte(cp, b);
}
}
}
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
#[test]
fn document_text_in_windows_dos_and_mac_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
assert_eq!(decode(10000, &[0x8E]), "é");
assert_eq!(decode(1251, &[0xC4]), "Д");
for cp in [437, 850, 866, 1250, 1252, 10000] {
assert!(is_byte_code_page(cp), "{cp}");
}
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
}
#[test]
fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
for cp in OEM_CODE_PAGES {
for b in 0..=255u8 {
let _ = oem_byte(cp, b);
}
}
}
}
+1 -13
View File
@@ -45,7 +45,7 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
let path = Path(prefix.clone());
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::FieldStart { instr, locked, .. }) => {
Some(InlineObject::FieldStart { instr, locked }) => {
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
}
Some(InlineObject::FieldEnd) => match open.pop() {
@@ -93,18 +93,6 @@ impl Document {
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
}
/// `p` moved past the range-field ends right after it. A click just after a field's result
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
/// after the field, as in Word, not in its result.
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
}
/// Whether any story holds a range-field marker without its partner.
pub fn has_unbalanced_field_ranges(&self) -> bool {
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
+7 -11
View File
@@ -1,4 +1,4 @@
//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt
//! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`],
//! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited.
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
use crate::props::Rgb;
/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt
/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt
/// graphic, its model).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -22,9 +22,9 @@ pub struct Graphic {
pub w: f32,
#[serde(default)]
pub h: f32,
/// Where the graphic was read from, so saving writes the original drawing itself back (not
/// Where the graphic was read from, so saving writes the chart or diagram itself back (not
/// only these items). `None` for graphics made some other way.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip)]
pub source: Option<std::sync::Arc<Embedded>>,
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
@@ -41,18 +41,17 @@ pub struct Graphic {
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
/// and resizes the object around it. The parts themselves (bytes, content type and their own
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
/// or the `w:object` of an OLE object).
/// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object).
pub xml: String,
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
pub refs: Vec<EmbeddedRef>,
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EmbeddedRef {
/// The relationship id the markup uses.
pub id: String,
@@ -69,8 +68,6 @@ pub enum GraphicKind {
#[default]
Chart,
Diagram,
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
Ink,
}
impl GraphicKind {
@@ -79,7 +76,6 @@ impl GraphicKind {
match self {
GraphicKind::Chart => "chart",
GraphicKind::Diagram => "diagram",
GraphicKind::Ink => "ink",
}
}
}
+12 -214
View File
@@ -14,7 +14,6 @@ pub mod bidi;
pub mod chart;
mod cjk_numbers;
pub mod cluster;
pub mod collate;
pub mod connector;
pub mod control;
pub mod edit;
@@ -25,7 +24,6 @@ pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod link;
pub mod locale_number;
pub mod math;
pub mod math_edit;
pub mod math_latex;
@@ -38,7 +36,6 @@ pub mod resolve;
pub mod section;
pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod wrap;
@@ -71,27 +68,6 @@ pub enum DocError {
pub type Result<T> = std::result::Result<T, DocError>;
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
let Some(largest) = largest else { return Some(min) };
if let Some(next) = largest.checked_add(1) {
return Some(next.max(min));
}
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
ids.sort_unstable();
ids.dedup();
let mut want = min;
for i in ids {
if i > want {
break;
}
want = want.checked_add(1)?;
}
Some(want)
}
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -160,11 +136,6 @@ impl BoxBudget {
self.open.pop();
}
/// Whether a text box is being expanded now.
pub fn inside(&self) -> bool {
!self.open.is_empty()
}
/// Boxes expanded so far.
pub fn total(&self) -> usize {
self.total
@@ -303,29 +274,12 @@ pub struct TextBody {
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
/// story; its own blocks are unused. See [`Document::text_box_chains`].
pub next: Option<u32>,
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
/// `None` is Word's defaults.
pub inset: Option<[u32; 4]>,
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
/// box to its text, so none of the text is cut.
pub auto_fit: bool,
}
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
/// Widest text box margin kept, EMU (22", the largest page side).
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
impl TextBody {
pub fn is_default(&self) -> bool {
*self == TextBody::default()
}
/// The internal margins (left, top, right, bottom) in points.
pub fn insets_pt(&self) -> [f32; 4] {
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
}
}
/// Most boxes one chain of linked text boxes runs through (hostile files).
@@ -343,13 +297,6 @@ pub struct Comment {
pub resolved: bool,
/// `Document::parts` id of the comment text.
pub part: u32,
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -461,8 +408,6 @@ pub struct Settings {
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
pub consecutive_hyphen_limit: u32,
pub page_color: Option<Rgb>,
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
pub page_picture: Option<PagePicture>,
pub watermark: Option<Watermark>,
/// Theme fonts (major = headings, minor = body).
pub major_font: String,
@@ -494,17 +439,6 @@ pub struct Settings {
/// 2013 and later, which places a table's border at the margin rather than its text and lets
/// justified lines shrink their spaces to fit more text.
pub compat_mode: u32,
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
/// draws one that isn't installed in a serif face.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roman_fonts: Vec<String>,
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
pub grid_h: f32,
pub grid_v: f32,
@@ -521,37 +455,6 @@ pub struct Settings {
pub attached_template: Option<String>,
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
pub link_styles: bool,
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
/// without a fit mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<Zoom>,
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
/// the document's creator and last-modified-by. The open document keeps its real values.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_personal_info: bool,
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Zoom {
/// 10–500.
pub percent: u32,
pub fit: Option<ZoomFit>,
}
/// View › Zoom's fit modes a document can be saved in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ZoomFit {
OnePage,
PageWidth,
TextWidth,
}
/// Word's compatibility mode for documents that don't state one.
@@ -566,18 +469,6 @@ pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
pub const DEFAULT_GRID: f32 = 9.0;
/// A picture fill for the page background (`v:background/v:fill` with an image).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PagePicture {
/// Key into [`Document::media`].
pub media: String,
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
pub fill_type: String,
/// `o:title` of the fill (texture name), kept for round trip.
pub title: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Watermark {
@@ -622,7 +513,6 @@ impl Default for Settings {
hyphenate_caps: true,
consecutive_hyphen_limit: 0,
page_color: None,
page_picture: None,
watermark: None,
major_font: styles::HEADING_FONT.into(),
minor_font: styles::BODY_FONT.into(),
@@ -636,18 +526,12 @@ impl Default for Settings {
protect_formatting: false,
protect_hash: Vec::new(),
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
bib_style: None,
attached_template: None,
link_styles: false,
zoom: None,
remove_personal_info: false,
remove_date_time: false,
}
}
}
@@ -677,10 +561,10 @@ pub struct Document {
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub endnote_separators: NoteSeparators,
/// Embedded media (images) by key.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
#[serde(skip)]
pub media: BTreeMap<String, Arc<Vec<u8>>>,
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
#[serde(skip)]
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
}
@@ -894,24 +778,9 @@ impl Document {
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
pub fn plain_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Plain)
}
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
/// Unicode equivalents ([`Paragraph::shown_text`]).
pub fn shown_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Shown)
}
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
pub fn final_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Final)
}
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
let mut out = String::new();
if let Some(bl) = self.story(s) {
text_of(bl, &mut out, 0, mode);
text_of(bl, &mut out, 0);
}
if out.ends_with('\n') {
out.pop();
@@ -978,40 +847,25 @@ impl Document {
&mut self.last_section
}
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
/// Allocate a new part id and insert the part.
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
self.try_add_part(kind, blocks).unwrap_or(0)
}
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1);
let mut part = Part { kind, blocks, body: TextBody::default() };
if part.blocks.is_empty() {
part.blocks.push(para_block(Paragraph::new()));
}
self.parts.insert(id, part);
Ok(id)
id
}
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
/// including generated numbered-list labels).
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit).
pub fn word_count(&self) -> usize {
self.story_word_count(StoryRef::Body)
count_words(&self.plain_text(StoryRef::Body))
}
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
pub fn word_count_including_notes(&self) -> usize {
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
}
/// Word count of one story, including generated numbered-list labels.
pub fn story_word_count(&self, s: StoryRef) -> usize {
let Some(bl) = self.story(s) else { return 0 };
let mut counters = numbering::Counters::default();
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
self.counted_stories().iter().map(|s| count_words(&self.plain_text(*s))).sum()
}
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
@@ -1250,18 +1104,6 @@ impl Document {
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
}
}
}
/// Number of paragraphs in the body (all depths).
pub fn paragraph_count(&self) -> usize {
self.para_paths(StoryRef::Body).len()
@@ -1411,29 +1253,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
}
}
/// Which text of a paragraph [`text_of`] collects.
#[derive(Clone, Copy)]
enum TextMode {
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
Plain,
/// As shown on screen ([`Paragraph::shown_text`]).
Shown,
/// Without tracked deletions ([`Paragraph::final_text`]).
Final,
}
fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
if depth > 16 {
return;
}
for b in bl {
match &**b {
Block::Para(p) => {
out.push_str(&match mode {
TextMode::Plain => p.plain_text(),
TextMode::Shown => p.shown_text(),
TextMode::Final => p.final_text(),
});
out.push_str(&p.plain_text());
out.push('\n');
}
Block::Table(t) => {
@@ -1441,7 +1268,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
let mut cells = Vec::new();
for c in &row.cells {
let mut s = String::new();
text_of(&c.blocks, &mut s, depth + 1, mode);
text_of(&c.blocks, &mut s, depth + 1);
cells.push(s.trim_end_matches('\n').replace('\n', " "));
}
out.push_str(&cells.join("\t"));
@@ -1452,34 +1279,5 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
}
}
fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize {
if depth > 16 {
return 0;
}
let mut total = 0usize;
for b in bl {
match &**b {
Block::Para(p) => {
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
if let Some(n) = n
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
{
total = total.saturating_add(count_words(&label));
}
total = total.saturating_add(count_words(&p.plain_text()));
}
Block::Table(t) => {
for row in &t.rows {
for c in &row.cells {
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
}
}
}
}
}
total
}
#[cfg(test)]
mod tests;
+5 -19
View File
@@ -9,23 +9,14 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
pub fn is_safe_href(url: &str) -> bool {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
return false;
}
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !cleaned.is_empty(),
None => !url.trim().is_empty(),
}
}
@@ -82,11 +73,6 @@ mod tests {
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
"//attacker.example/share/x",
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-177
View File
@@ -1,177 +0,0 @@
//! Numbers written the way a language writes them: `1 250,50` in Polish, `1.250,50` in German,
//! `1,250.50` in English. Used by table sort and table formulas.
use crate::collate::language;
/// The decimal separator of language `lang` (a BCP 47 tag; `None` means English).
pub fn decimal_separator(lang: Option<&str>) -> char {
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
// Regions that write a decimal point where the language usually writes a comma.
const POINT_REGIONS: &[&str] =
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"];
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
return '.';
}
const COMMA: &[&str] = &[
"af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it",
"ka", "kk", "ky", "lb", "lt", "lv", "mk", "mn", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk",
"uz", "vi",
];
if COMMA.contains(&language(lang).as_str()) { ',' } else { '.' }
}
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
{
s = head;
}
if let Some((word, tail)) = s.split_once(SPACES)
&& word.chars().count() == 3
&& word.chars().all(|c| c.is_ascii_uppercase())
{
s = tail;
}
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Digits of an integer part with group separators `groups`: the first group has 1–3 digits,
/// every later one exactly 3.
fn grouped_digits(int: &str, groups: &[char]) -> Option<String> {
if int.chars().all(|c| c.is_ascii_digit()) {
return Some(int.to_string());
}
let parts: Vec<&str> = int.split(groups).collect();
let (first, rest) = parts.split_first()?;
// A first group of `0` is never grouping (`0.125` is a fraction, not 125).
let ok = (1..=3).contains(&first.len())
&& !first.starts_with('0')
&& first.chars().all(|c| c.is_ascii_digit())
&& rest.iter().all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit()));
ok.then(|| parts.concat())
}
/// Parse `s` as a number written in language `lang`, or `None` when it isn't one. The decimal
/// separator is the language's; group separators are spaces (and `.` or `'` where the decimal
/// separator is a comma, `,` or `'` where it is a point) between groups of three digits. A
/// currency sign or short currency word is ignored, and so is a trailing `%` (`12 %` is 12, as
/// table sort reads it). In comma languages a lone `.` that cannot be a group separator (`1.5`)
/// is read as a decimal point. A trailing separator with no digits after it (`1.`, an ordinal
/// in a numbered column) is the whole number. A minus may be `-`, `−` or an en dash.
pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
let dec = decimal_separator(lang);
let s = s.trim();
let s = s.strip_suffix('%').map_or(s, str::trim_end);
let mut body = strip_currency(s);
let mut neg = false;
if let Some(rest) = body.strip_prefix(['-', '\u{2212}', '\u{2013}']) {
neg = true;
body = strip_currency(rest);
} else if let Some(rest) = body.strip_prefix('+') {
body = strip_currency(rest);
}
if body.is_empty() || body.len() > 400 {
return None;
}
let mut groups: Vec<char> = SPACES.to_vec();
groups.extend(['\'', '\u{2019}']);
groups.push(if dec == ',' { '.' } else { ',' });
let (int, frac) = match body.split_once(dec) {
Some((i, f)) => (i, Some(f)),
None => (body, None),
};
let int_digits = match grouped_digits(int, &groups) {
Some(d) => d,
None if dec == ',' && frac.is_none() && int.matches('.').count() == 1 => {
return parse_number(&format!("{}{}", if neg { "-" } else { "" }, int.replace('.', ",")), lang);
}
None => return None,
};
let frac_digits = match frac {
Some(f) if f.chars().all(|c| c.is_ascii_digit()) => f,
Some(_) => return None,
None => "",
};
if int_digits.is_empty() && frac_digits.is_empty() {
return None;
}
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
Some(if neg { -v } else { v })
}
/// Write `v` the way language `lang` writes a formula result: whole numbers without decimals,
/// others with two decimals and the language's decimal separator (`5,75` in Polish).
pub fn format_number(v: f64, lang: Option<&str>) -> String {
if !v.is_finite() {
return "0".into();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
return format!("{}", v as i64);
}
let s = format!("{v:.2}");
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polish_numbers() {
let pl = Some("pl-PL");
assert_eq!(parse_number("1,5", pl), Some(1.5));
assert_eq!(parse_number("3,50", pl), Some(3.5));
assert_eq!(parse_number("1 250,00", pl), Some(1250.0));
assert_eq!(parse_number("1\u{a0}250,00 zł", pl), Some(1250.0));
assert_eq!(parse_number("1.250,00", pl), Some(1250.0));
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
assert_eq!(parse_number("1.", pl), Some(1.0), "a numbered column: 1.");
assert_eq!(parse_number("10.", pl), Some(10.0));
assert_eq!(parse_number("0.125", pl), Some(0.125), "a leading 0 is never a group");
assert_eq!(parse_number("0,125", pl), Some(0.125));
assert_eq!(parse_number("12 %", pl), Some(12.0));
assert_eq!(parse_number("12%", pl), Some(12.0));
assert_eq!(parse_number("\u{2013}2,5", pl), Some(-2.5));
assert_eq!(parse_number("%", pl), None);
assert_eq!(parse_number(".", pl), None);
assert_eq!(format_number(5.75, pl), "5,75");
assert_eq!(format_number(1350.0, pl), "1350");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
assert_eq!(parse_number("1,5", en), None);
assert_eq!(parse_number("1.", en), Some(1.0));
assert_eq!(parse_number("0,125", en), None, "never 125");
assert_eq!(format_number(5.75, en), "5.75");
assert_eq!(decimal_separator(Some("de-CH")), '.');
assert_eq!(decimal_separator(Some("de-DE")), ',');
}
#[test]
fn hostile_numbers_never_panic() {
for s in ["", "-", "+", ",", ".", "1e400", "inf", "NaN", "--1", "₽", "1 ,", " zł", "\u{2212}", "9".repeat(500).as_str()] {
for l in [None, Some("pl"), Some("en"), Some("")] {
let _ = parse_number(s, l);
}
}
assert_eq!(format_number(f64::NAN, None), "0");
}
}
+20 -200
View File
@@ -340,7 +340,7 @@ fn yes() -> bool {
/// A row holds an equation-number mark (`#`) in a top-level run.
pub fn has_number_mark(row: &[MNode]) -> bool {
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
}
/// Integral signs (their limits default to sub/sup positions).
@@ -499,21 +499,6 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
}
}
/// A script or limit operand. The parser reads a script as one letter or a number (`x_10`), so a
/// longer run is grouped: `V_(dd)`, not `V_dd` (which reads back as V_d then d).
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
if let [MNode::Run(r)] = a
&& r.text.chars().nth(1).is_some()
&& !(r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_ascii_digit() || c == '.'))
{
s.push('(');
s.push_str(&r.text);
s.push(')');
} else {
lin_operand(a, s, depth);
}
}
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => s.push_str(&r.text),
@@ -530,9 +515,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Script { kind, base, sub, sup } => {
if *kind == ScriptKind::Pre {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
s.push('▒');
lin_operand(base, s, depth);
return;
@@ -540,11 +525,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
lin_operand(base, s, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
}
}
MNode::Rad { deg, deg_hide, e } => {
@@ -563,11 +548,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
s.push(*chr);
if !sub_hide && !sub.is_empty() {
s.push('_');
lin_script(sub, s, depth);
lin_operand(sub, s, depth);
}
if !sup_hide && !sup.is_empty() {
s.push('^');
lin_script(sup, s, depth);
lin_operand(sup, s, depth);
}
s.push('▒');
lin_operand(e, s, depth);
@@ -607,7 +592,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Lim { upper, e, lim } => {
lin_operand(e, s, depth);
s.push(if *upper { '┴' } else { '┬' });
lin_script(lim, s, depth);
lin_operand(lim, s, depth);
}
MNode::Acc { chr, e } => {
lin_operand(e, s, depth);
@@ -716,7 +701,7 @@ pub fn math_class(c: char) -> MClass {
}
}
/// Merge adjacent runs with the same formatting; recurse into every structure.
/// Merge adjacent runs with the same formatting; recurse into structures.
pub fn merge_runs(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -737,79 +722,23 @@ pub fn merge_runs(a: Arg) -> Arg {
}
MNode::Run(r)
}
other => crate::math_linear::map_args(other, &merge_runs),
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
}
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
}
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
other => other,
};
out.push(n);
}
out
}
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
/// stops reading at 128 nested containers and a node costs two (object and array).
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
/// Calls `f` with every argument of `n`.
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
match n {
MNode::Run(_) => {}
MNode::Frac { num, den, .. } => {
f(num);
f(den);
}
MNode::Script { base, sub, sup, .. } => {
f(base);
f(sub);
f(sup);
}
MNode::Rad { deg, e, .. } => {
f(deg);
f(e);
}
MNode::Nary { sub, sup, e, .. } => {
f(sub);
f(sup);
f(e);
}
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
MNode::Func { name, e } => {
f(name);
f(e);
}
MNode::Lim { e, lim, .. } => {
f(e);
f(lim);
}
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. } => f(e),
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
}
}
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
/// size of `n`, which the parsers' input caps bound.
pub(crate) fn too_deep(n: &MNode) -> bool {
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
while let Some((n, d)) = stack.pop() {
if d >= WRAP_DEPTH {
return true;
}
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
false
}
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
a.iter().any(too_deep)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -878,115 +807,6 @@ mod tests {
}
}
//T1
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
fn depth_of(a: &[MNode]) -> usize {
let mut best = 0;
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
while let Some((n, d)) = stack.pop() {
best = best.max(d);
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
best
}
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
}
#[test]
fn deeply_chained_input_stays_shallow() {
on_small_stack(|| {
let cases: Vec<(&str, Arg)> = vec![
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
];
for (name, n) in cases {
assert!(!n.is_empty(), "{name}");
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
let copy = n.clone();
assert_eq!(copy, n, "{name}");
let _ = to_linear(&n);
let _ = crate::math_latex::to_latex(&n);
let json = serde_json::to_string(&n).unwrap();
let back: Arg = serde_json::from_str(&json).unwrap();
assert_eq!(back, n, "{name}");
}
});
}
#[test]
fn ordinary_nesting_is_still_built() {
// Well below the cap: scripts, fractions and operators still wrap.
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
assert_eq!(depth_of(&n), 11, "{n:#?}");
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
}
//T2
#[test]
fn latex_text_escapes_what_latex_reads_as_markup() {
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
let tex = crate::math_latex::to_latex(&n);
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
}
let n = vec![MNode::Run(MRun::new("a\\b"))];
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
}
//T3
#[test]
fn latex_runs_merge_inside_every_structure() {
use crate::math_latex::parse_latex;
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
}
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
}
#[test]
fn multi_letter_scripts_round_trip() {
// A script reads one letter or a number, so `V_dd` is V_d then d: longer scripts are grouped.
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
for n in [
vec![script(ScriptKind::Sub, "dd", "")],
vec![script(ScriptKind::Sup, "", "ab")],
vec![script(ScriptKind::SubSup, "in", "2")],
vec![MNode::Nary {
chr: '∑',
lim_loc: None,
grow: false,
sub_hide: false,
sup_hide: false,
sub: vec![run("ij")],
sup: vec![run("n")],
e: vec![run("x")],
}],
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
] {
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
}
// Numbers and single letters stay bare.
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
}
#[test]
fn hostile_linear_never_panics() {
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
-35
View File
@@ -362,26 +362,6 @@ pub fn tidy(root: &mut Arg) {
go(root, 0);
}
/// Call `f` on every run in the tree.
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a.iter_mut() {
if let MNode::Run(r) = n {
f(r);
}
for c in 0..child_count(n) {
if let Some(ch) = child_mut(n, c) {
go(ch, f, depth + 1);
}
}
}
}
go(root, f, 0);
}
fn merge_shallow(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -1199,21 +1179,6 @@ mod tests {
assert!(matches!(eq[0], MNode::Frac { .. }));
}
#[test]
fn build_up_keeps_a_multi_letter_subscript() {
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
let mut eq: Arg = Vec::new();
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
assert_eq!(p, MathPos::new(vec![], 1));
let built = eq.clone();
assert_eq!(build_up(&mut eq, &p, false), None);
assert_eq!(eq, built, "{}", lin(&eq));
}
#[test]
fn backspace_dissolves_and_removes() {
let mut eq = parse_linear("√x");
+10 -48
View File
@@ -5,9 +5,7 @@
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
use crate::math::{
Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
};
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math_linear::{FUNCTION_NAMES, map_args};
use crate::math_symbols::{name_of, symbol};
@@ -111,7 +109,7 @@ pub fn parse_latex(s: &str) -> Arg {
p.i += 1;
}
let out = merge_runs(out);
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
return vec![MNode::EqArr { rows: vec![out] }];
}
out
@@ -149,13 +147,8 @@ impl Lp {
Tok::Sup | Tok::Sub => {
self.i += 1;
let s = self.arg();
if out.last().is_some_and(too_deep) {
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
out.extend(s);
} else {
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
self.i += 1;
@@ -178,7 +171,7 @@ impl Lp {
}
self.depth -= 1;
// Big operators take the following term as their operand.
nary_operands(out, self.depth)
nary_operands(out)
}
/// A command argument: a `{group}` or one token.
@@ -231,14 +224,10 @@ impl Lp {
}
self.i += 1;
let mut level = 0i32;
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
let mut after_word = false;
while let Some(t) = self.peek().cloned() {
self.i += 1;
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
match t {
Tok::Close if level == 0 => break,
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
Tok::Open => {
level += 1;
s.push('{');
@@ -526,10 +515,6 @@ impl Lp {
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
self.i += 1;
let s = self.arg();
if any_too_deep(&e) {
e.extend(s);
continue;
}
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
e.push(script(base, t == Tok::Sup, s));
}
@@ -644,14 +629,12 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
}
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
/// (`\sum\sum\sum…` must not nest once per operator).
fn nary_operands(a: Arg, depth: usize) -> Arg {
fn nary_operands(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
let mut it = a.into_iter().peekable();
while let Some(n) = it.next() {
match n {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
let mut body = Vec::new();
while let Some(next) = it.peek() {
let stop = match next {
@@ -667,14 +650,8 @@ fn nary_operands(a: Arg, depth: usize) -> Arg {
body.push(x);
}
}
let body = merge_runs(nary_operands(body, depth + 1));
if any_too_deep(&body) {
// No room to wrap the operand: it follows the operator.
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
out.extend(body);
} else {
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
}
let body = nary_operands(body);
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
}
other => out.push(other),
}
@@ -748,21 +725,6 @@ fn chars(text: &str, s: &mut String) {
}
}
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
fn text_chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
s.push('\\');
s.push(c);
}
// `{}` ends the command name without adding a space (spaces count in text).
'\\' => s.push_str("\\backslash{}"),
c => s.push(c),
}
}
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -802,7 +764,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
};
s.push_str(open);
if r.nor {
text_chars(&r.text, s);
s.push_str(&r.text);
} else {
chars(&r.text, s);
}
+13 -40
View File
@@ -9,7 +9,7 @@
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
/// Function names set upright and applied to the following operand.
pub const FUNCTION_NAMES: &[&str] = &[
@@ -135,11 +135,6 @@ impl P {
'/' | '⁄' | '∕' | '¦' => {
self.i += 1;
let start = operand_start(&out);
// A numerator that is already deep stays as it is: `/` is text then.
if out.get(start..).is_some_and(any_too_deep) {
out.push(text(c));
continue;
}
let num = strip_parens(out.drain(start..).collect());
let den = self.operand(stops);
let kind = match c {
@@ -161,34 +156,20 @@ impl P {
} else {
self.i += 1;
let s = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(s);
} else {
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
}
'┬' | '┴' => {
self.i += 1;
let lim = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(lim);
} else {
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
c if is_combining(c) => {
self.i += 1;
if out.last().is_some_and(too_deep) {
out.push(text(c));
} else {
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
'▒' | '\u{2061}' => self.i += 1,
' ' => {
@@ -556,33 +537,25 @@ impl P {
}
out.pop()
};
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
self.i += 1;
if deep {
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
out.push(text(c));
out.extend(s);
continue;
}
match c {
'^' | '_' => {
self.i += 1;
let s = self.script_operand(stops);
let b = base(out);
out.push(attach_script(b, c == '^', s));
}
'┬' | '┴' => {
'┬' | '┴' if self.no_limits == 0 => {
self.i += 1;
let lim = self.script_operand(stops);
let b = base(out);
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
}
_ => {
c if is_combining(c) => {
self.i += 1;
let b = base(out);
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
}
_ => break,
}
}
}
-1
View File
@@ -85,7 +85,6 @@ pub const SYMBOLS: &[(&str, char)] = &[
("vee", '∨'),
("wedge", '∧'),
("setminus", '∖'),
("backslash", '\\'),
("wr", '≀'),
("diamond", '⋄'),
("dagger", '†'),
+10 -68
View File
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
use crate::props::{Align, CharProps};
use crate::section::NumFormat;
use crate::{DocError, free_id};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -76,17 +75,6 @@ pub struct AbstractNum {
pub id: u32,
pub name: Option<String>,
pub levels: Vec<Level>,
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
/// documents (paste, compare); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub nsid: Option<String>,
/// The list template the definition came from (`w:tmpl`); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub tmpl: Option<String>,
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
/// `None`: derived from the levels.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_level_type: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -220,51 +208,26 @@ impl Numbering {
let n = self.num(num)?;
self.abstracts.iter().find(|a| a.id == n.abstract_id)
}
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
return Some(l);
}
self.abstract_of(num)?.levels.get(level as usize)
}
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
/// Create a new list of `kind`; returns the `num` id.
pub fn add_list(&mut self, kind: ListKind) -> u32 {
self.try_add_list(kind).unwrap_or(0)
}
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
self.try_add_levels(None, levels_for(kind))
}
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
/// an error, adding nothing, when they are all taken.
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
Ok(nid)
nid
}
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
fn free_num_id(&self) -> Option<u32> {
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
}
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
/// are all taken).
/// A new `num` that restarts numbering of an existing list.
pub fn restart(&mut self, num: u32) -> Option<u32> {
let source = self.num(num)?;
let a = source.abstract_id;
let level_overrides = source.level_overrides.clone();
let nid = self.free_num_id()?;
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides });
let a = self.num(num)?.abstract_id;
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() });
Some(nid)
}
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
@@ -288,12 +251,6 @@ pub struct Counters {
}
impl Counters {
/// The current value of each level of `num`'s list (0 for a level not counted yet).
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
let abs = numbering.abstract_of(num)?;
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
}
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
let n = numbering.num(num)?;
@@ -470,19 +427,4 @@ mod tests {
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
}
#[test]
fn restart_keeps_the_list_s_own_level_definitions() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides.push((0, own.clone()));
}
let r = n.restart(id).unwrap();
assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]);
assert_eq!(n.level(r, 0), Some(&own));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V");
}
}
+24 -99
View File
@@ -212,10 +212,6 @@ pub struct Float {
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -288,11 +284,7 @@ pub enum ShapeKind {
Rectangle,
RoundedRectangle,
Ellipse,
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
FlowchartOr,
Triangle,
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
RightTriangle,
Diamond,
Line,
Arrow,
@@ -384,7 +376,7 @@ pub enum InlineObject {
crop: [f32; 4],
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip)]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
@@ -448,20 +440,11 @@ pub enum InlineObject {
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
@@ -471,9 +454,6 @@ pub enum InlineObject {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
@@ -851,30 +831,6 @@ impl Paragraph {
}
out
}
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
/// (Symbol, Wingdings…) become their standard Unicode equivalents
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
/// unchanged.
pub fn shown_text(&self) -> String {
let mut out = String::with_capacity(self.text.len());
let mut k = 0;
let mut runs = self.run_ranges().peekable();
for (i, c) in self.text.char_indices() {
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
runs.next();
}
if c == OBJ {
if let Some(o) = self.objects.get(k) {
out.push_str(o.plain_text());
}
k += 1;
continue;
}
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
}
out
}
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
pub fn final_text(&self) -> String {
self.text_without(&self.deleted_ranges())
@@ -1045,32 +1001,32 @@ impl Paragraph {
/// Merge adjacent equal runs and drop empty ones.
pub fn normalize(&mut self) {
// Runs must end on character boundaries and cover the text exactly (never expected, but
// files lie): clamp each end to the text, push it past a split character, pad the tail.
let n = self.text.len();
let mut end = 0usize;
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
for r in self.runs.drain(..) {
let mut e = end.saturating_add(r.len).min(n);
while !self.text.is_char_boundary(e) {
e += 1;
}
if e == end {
if r.len == 0 {
continue;
}
match out.last_mut() {
Some(l) if l.props == r.props => l.len += e - end,
_ => out.push(Run { len: e - end, props: r.props }),
}
end = e;
}
if end < n {
match out.last_mut() {
Some(l) => l.len += n - end,
None => out.push(Run { len: n, props: self.mark.clone() }),
Some(l) if l.props == r.props => l.len += r.len,
_ => out.push(r),
}
}
self.runs = out;
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
let total: usize = self.runs.iter().map(|r| r.len).sum();
if total != self.text.len() {
if total < self.text.len() {
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
self.runs.push(Run { len: self.text.len() - total, props: p });
} else {
let mut keep = self.text.len();
for r in &mut self.runs {
r.len = r.len.min(keep);
keep -= r.len;
}
self.runs.retain(|r| r.len > 0);
}
}
}
/// Delete `a..b` (bytes). Objects inside are removed.
@@ -1084,12 +1040,7 @@ impl Paragraph {
if self.runs.is_empty() || a == 0 {
// Keep the formatting of what's deleted for the mark when emptying.
if b == self.text.len() {
// Formatting only: the mark is a tracked change of its own or it is not, whatever
// revision the deleted text was part of.
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
let mut mark = self.props_of_char(a).clone();
(mark.ins, mark.del, mark.fmt_change) = revisions;
self.mark = mark;
self.mark = self.props_of_char(a).clone();
}
}
let k0 = self.objects_before(a);
@@ -1361,12 +1312,8 @@ mod tests {
fn text_without_takes_ranges_in_any_order() {
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
let mut p = Paragraph::with_text("añb€c", CharProps::default());
p.insert_object(
3,
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
let text = p.text.clone();
let naive = |dropped: &[std::ops::Range<usize>]| {
let mut out = String::new();
@@ -1435,12 +1382,8 @@ mod tests {
#[test]
fn objects_track_text() {
let mut p = Paragraph::with_text("ab", CharProps::default());
p.insert_object(
1,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
assert_eq!(p.objects.len(), 2);
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
@@ -1469,24 +1412,6 @@ mod tests {
assert_eq!(t.mark.bold, Some(true));
}
#[test]
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
let mut props = bold();
props.ins = Some(3);
props.del = Some(4);
let mut p = Paragraph::with_text("Hello", props);
p.mark = bold();
p.delete(0, 5).unwrap();
assert!(p.is_empty());
assert_eq!(p.mark.bold, Some(true));
assert_eq!((p.mark.ins, p.mark.del), (None, None));
// A mark that is itself a tracked change keeps its own revision.
let mut p = Paragraph::with_text("Hello", bold());
p.mark.del = Some(7);
p.delete(0, 5).unwrap();
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
}
#[test]
fn words() {
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
+21 -175
View File
@@ -6,38 +6,9 @@
use serde::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl<'de> Deserialize<'de> for Rgb {
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
struct V;
impl<'de> Visitor<'de> for V {
type Value = Rgb;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
}
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
let mut c = [0u8; 3];
for (i, x) in c.iter_mut().enumerate() {
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
}
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(Rgb(c[0], c[1], c[2]))
}
}
d.deserialize_any(V)
}
}
impl Rgb {
pub const BLACK: Rgb = Rgb(0, 0, 0);
pub const WHITE: Rgb = Rgb(255, 255, 255);
@@ -60,42 +31,13 @@ impl Rgb {
}
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum TextColor {
#[default]
Auto,
Rgb(Rgb),
}
impl TextColor {
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
pub fn parse(s: &str) -> Option<TextColor> {
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
}
}
impl<'de> Deserialize<'de> for TextColor {
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, Unexpected};
#[derive(Deserialize)]
enum Written {
Auto,
Rgb(Rgb),
}
match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => {
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
}
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
Written::Auto => TextColor::Auto,
Written::Rgb(c) => TextColor::Rgb(c),
}),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Underline {
@@ -361,10 +303,10 @@ pub struct CharProps {
/// inherit, then `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
/// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub size_cs: Option<f32>,
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`.
#[serde(skip_serializing_if = "Option::is_none")]
pub bold_cs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -448,8 +390,11 @@ impl CharProps {
/// Apply every `Some` field of `patch` on top of `self`.
///
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
/// Size, bold and italic come in pairs with their complex-script values (Word's own buttons
/// set both): a patch that sets the plain value and leaves the complex-script one `None` sets
/// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an
/// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often
/// sets only the Latin font and lets complex-script text keep the style's).
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
@@ -457,6 +402,15 @@ impl CharProps {
if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
}
if patch.size.is_some() && patch.size_cs.is_none() {
self.size_cs = None;
}
if patch.bold.is_some() && patch.bold_cs.is_none() {
self.bold_cs = None;
}
if patch.italic.is_some() && patch.italic_cs.is_none() {
self.italic_cs = None;
}
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
self.overlay(patch);
@@ -554,10 +508,6 @@ impl TabLeader {
}
}
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
pub const TAB_POS_EPS: f32 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TabStop {
/// Points from the paragraph's left indent origin (the text column's left edge).
@@ -688,33 +638,6 @@ pub struct NumRef {
pub level: u8,
}
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextFrame {
/// Width; `None` fits the text.
pub w: Option<f32>,
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
pub h: Option<f32>,
pub h_exact: bool,
pub x: f32,
pub y: f32,
/// `left`, `center`, `right`, `inside`, `outside`.
pub x_align: Option<String>,
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
pub y_align: Option<String>,
/// `text` (the column), `margin` or `page`.
pub h_anchor: String,
/// `text` (the paragraph), `margin` or `page`.
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
}
/// Paragraph formatting. `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -748,13 +671,6 @@ pub struct ParaProps {
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
/// document or of a table cell, and collapsing with the space after the paragraph above.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before_auto: Option<bool>,
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after_auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<LineSpacing>,
/// Ignore space before/after between paragraphs of the same style.
@@ -793,10 +709,6 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
/// text, which may wrap around it — a footer's page number in a frame, for instance.
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<TextFrame>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
@@ -831,9 +743,9 @@ pub struct ParaProps {
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
@@ -1078,31 +990,11 @@ pub struct RowProps {
pub header: bool,
/// Don't let the row break across pages.
pub cant_split: bool,
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
#[serde(skip_serializing_if = "Option::is_none")]
pub exceptions: Option<Box<TableProps>>,
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
/// width of that gap in points (`w:wBefore`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_before: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_before: Option<f32>,
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
/// (`w:wAfter`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_after: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_after: Option<f32>,
/// Tracked formatting change (`w:trPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<RowProps>>>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VMerge {
@@ -1127,22 +1019,12 @@ pub struct CellProps {
pub vmerge: VMerge,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
/// `Top` beats the style.
#[serde(skip_serializing_if = "Option::is_none")]
pub valign: Option<VAlign>,
pub valign: VAlign,
/// Cell margins override (top, left, bottom, right), points.
pub margins: Option<[f32; 4]>,
/// Which way the cell's text runs.
pub text_direction: TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hide_mark: bool,
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
@@ -1213,42 +1095,6 @@ mod tests {
assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
}
/// #865: a colour reads as `format.color` takes it (`RRGGBB`, `#RRGGBB`, `auto`), while what
/// documents and settings already hold reads and writes exactly as before.
#[test]
fn text_color_reads_hex_and_keeps_its_serialized_forms() {
let read = |j: &str| serde_json::from_str::<TextColor>(j);
let ink = TextColor::Rgb(Rgb(0x1B, 0x16, 0x11));
assert_eq!(read(r#""1B1611""#).unwrap(), ink);
assert_eq!(read(r##""#1b1611""##).unwrap(), ink);
assert_eq!(read(r#""auto""#).unwrap(), TextColor::Auto);
assert_eq!(read(r#""Automatic""#).unwrap(), TextColor::Auto);
for (c, j) in [(TextColor::Auto, r#""Auto""#), (ink, r#"{"Rgb":[27,22,17]}"#)] {
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
assert_eq!(read(j).unwrap(), c);
}
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B16"}"#, r#"{"Rgb":[1,2]}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
assert!(read(bad).is_err(), "{bad}");
}
assert_eq!(read(r#"{"Rgb":"1B1611"}"#).unwrap(), ink);
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
}
/// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes.
#[test]
fn rgb_reads_hex_and_keeps_its_serialized_form() {
let read = |j: &str| serde_json::from_str::<Rgb>(j);
let ink = Rgb(0x1B, 0x16, 0x11);
assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before");
for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] {
assert_eq!(read(j).unwrap(), ink, "{j}");
}
for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] {
assert!(read(bad).is_err(), "{bad}");
}
}
#[test]
fn props_serde_skip_none() {
let p = CharProps { bold: Some(true), ..Default::default() };
+2 -27
View File
@@ -8,10 +8,6 @@ use crate::props::{
};
use crate::styles::{StyleKind, StyleSheet};
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
/// and `w:afterAutospacing` at any font size (measured).
pub const AUTO_SPACING: f32 = 14.0;
/// Concrete character formatting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -36,9 +32,6 @@ pub struct ResolvedChar {
pub spacing: f32,
pub scale: f32,
pub position: f32,
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
pub outline: bool,
pub shadow: bool,
pub emboss: bool,
@@ -89,7 +82,6 @@ impl ResolvedChar {
spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0),
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
emboss: c.emboss.unwrap_or(false),
@@ -116,11 +108,6 @@ impl ResolvedChar {
pub fn complex(&self) -> ResolvedChar {
ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() }
}
/// Whether the text is kerned: Word kerns only a run with Kerning for fonts set (`w:kern`) at
/// a size of at least that many points, never by default (measured in Word for Windows).
pub fn kerned(&self) -> bool {
self.kern > 0.0 && self.size >= self.kern
}
/// Does a character of this run use the complex-script properties?
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
@@ -151,12 +138,6 @@ pub struct ResolvedPara {
pub indent_first: f32,
pub space_before: f32,
pub space_after: f32,
/// The space before or after is automatic (HTML-style, [`AUTO_SPACING`]): see
/// `ParaProps::space_before_auto`.
#[serde(default)]
pub before_auto: bool,
#[serde(default)]
pub after_auto: bool,
pub line_spacing: LineSpacing,
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
@@ -172,9 +153,6 @@ pub struct ResolvedPara {
pub borders: Option<Borders>,
pub bidi: bool,
pub drop_cap: u8,
/// A text frame the paragraph sits in (see `ParaProps::frame`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<crate::props::TextFrame>,
pub suppress_hyphens: bool,
pub suppress_line_numbers: bool,
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
@@ -228,10 +206,8 @@ impl ResolvedPara {
indent_left: clampi(p.indent_left),
indent_right: clampi(p.indent_right),
indent_first: clampi(p.indent_first),
space_before: if p.space_before_auto == Some(true) { AUTO_SPACING } else { p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0) },
space_after: if p.space_after_auto == Some(true) { AUTO_SPACING } else { p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0) },
before_auto: p.space_before_auto == Some(true),
after_auto: p.space_after_auto == Some(true),
space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0),
space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0),
line_spacing: match p.line_spacing.unwrap_or_default() {
LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }),
LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }),
@@ -250,7 +226,6 @@ impl ResolvedPara {
borders: p.borders.filter(Borders::any_visible),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
+6 -78
View File
@@ -2,36 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::props::Borders;
/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23,
/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no
/// Justified.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PageVAlign {
#[default]
Top,
Center,
/// Justified (`both`): the room left on each page is shared out equally between the page's
/// paragraphs and tables, so the text runs from the top margin to the bottom one.
#[serde(alias = "both", alias = "justified")]
Justify,
Bottom,
}
impl PageVAlign {
/// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`,
/// `middle`); anything else is Top.
pub fn parse(v: &str) -> PageVAlign {
match v {
"center" | "middle" => PageVAlign::Center,
"justify" | "justified" | "both" => PageVAlign::Justify,
"bottom" => PageVAlign::Bottom,
_ => PageVAlign::Top,
}
}
}
use crate::props::{Borders, VAlign};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -256,7 +227,7 @@ impl NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
@@ -266,8 +237,6 @@ impl NumFormat {
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -306,6 +275,8 @@ impl NumFormat {
| NumFormat::DecimalHalfWidth
| NumFormat::Hex
| NumFormat::VietnameseCounting
| NumFormat::RussianLower
| NumFormat::RussianUpper
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
@@ -383,22 +354,6 @@ pub fn letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays).
const RUSSIAN_LETTERS: [char; 29] =
['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я'];
/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]).
pub fn russian_letters(n: u32) -> String {
if n == 0 {
return String::new();
}
let len = RUSSIAN_LETTERS.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а');
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -721,16 +676,8 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub page_num_set: bool,
pub line_numbers: Option<LineNumbering>,
pub valign: PageVAlign,
/// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file
/// states it. Kept so saving writes it back; editing does not act on it.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_prot: Option<bool>,
pub valign: VAlign,
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
@@ -738,9 +685,6 @@ pub struct SectionProps {
/// sections turn the page's text area (headers and footers stay horizontal).
#[serde(skip_serializing_if = "is_horizontal")]
pub text_direction: crate::props::TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
/// Tracked change of the section's properties (`w:sectPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
@@ -774,14 +718,11 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
form_prot: None,
valign: VAlign::Top,
page_borders: None,
rtl: false,
text_direction: crate::props::TextDirection::Horizontal,
text_direction_set: false,
fmt_change: None,
doc_grid: None,
footnote_pr: NoteProps::default(),
@@ -890,8 +831,6 @@ mod tests {
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
assert_eq!(NumFormat::RussianLower.format(0), "");
assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30);
for f in NumFormat::ALL {
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
@@ -899,17 +838,6 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
let labels: Vec<String> = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect();
assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб");
assert_eq!(NumFormat::RussianLower.format(59), "ааа");
assert_eq!(NumFormat::RussianLower.format(100), "нннн");
assert_eq!(NumFormat::RussianUpper.format(26), "Ы");
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
+3 -368
View File
@@ -3,12 +3,7 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::props::{
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline,
VAlign,
};
use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -46,10 +41,6 @@ pub struct Style {
/// (`w:locked`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
/// The default style of its kind (`w:default`), whatever its id: localized documents call
/// the default character, table and list styles something other than the English ids.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
/// Table style: conditional formatting for header row, banding etc.
pub table: Option<TableStyleParts>,
}
@@ -93,166 +84,6 @@ pub struct TableStyleParts {
pub col_band_fill: Option<Rgb>,
pub col_band_chr: CharProps,
pub col_band_borders: Option<Borders>,
/// Per-part formatting the named fields above don't carry: every field of the parts without
/// one (even bands, corner cells), and the cell text alignment of every part. Read and write
/// parts through [`TableStyleParts::part`] / [`TableStyleParts::set_part`].
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub regions: BTreeMap<TablePart, PartFormat>,
}
/// A conditional formatting part of a table style (`w:tblStylePr/@w:type`, ECMA-376 §17.7.6).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTable,
FirstRow,
LastRow,
FirstCol,
LastCol,
Band1Vert,
Band2Vert,
Band1Horz,
Band2Horz,
NeCell,
NwCell,
SeCell,
SwCell,
}
impl TablePart {
/// Every part, lowest precedence first (ECMA-376 §17.7.6: whole table, bands, first/last
/// column, first/last row, corner cells).
pub const ALL: [TablePart; 13] = [
TablePart::WholeTable,
TablePart::Band1Vert,
TablePart::Band2Vert,
TablePart::Band1Horz,
TablePart::Band2Horz,
TablePart::FirstCol,
TablePart::LastCol,
TablePart::FirstRow,
TablePart::LastRow,
TablePart::NeCell,
TablePart::NwCell,
TablePart::SeCell,
TablePart::SwCell,
];
/// The `w:type` value.
pub fn ooxml(self) -> &'static str {
match self {
TablePart::WholeTable => "wholeTable",
TablePart::FirstRow => "firstRow",
TablePart::LastRow => "lastRow",
TablePart::FirstCol => "firstCol",
TablePart::LastCol => "lastCol",
TablePart::Band1Vert => "band1Vert",
TablePart::Band2Vert => "band2Vert",
TablePart::Band1Horz => "band1Horz",
TablePart::Band2Horz => "band2Horz",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
}
}
pub fn from_ooxml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.ooxml() == s)
}
/// A part spanning whole rows (its left/right edges are the table's sides, `inside_v` the
/// edges between its cells).
pub fn is_row(self) -> bool {
matches!(self, TablePart::FirstRow | TablePart::LastRow | TablePart::Band1Horz | TablePart::Band2Horz)
}
/// A part spanning whole columns (its top/bottom edges are the table's, `between` the edges
/// between its cells).
pub fn is_column(self) -> bool {
matches!(self, TablePart::FirstCol | TablePart::LastCol | TablePart::Band1Vert | TablePart::Band2Vert)
}
fn bit(self) -> u16 {
1 << (TablePart::ALL.iter().position(|p| *p == self).unwrap_or(0) as u16)
}
}
/// The table style parts one cell is in (always the whole table).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PartSet(u16);
impl PartSet {
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
/// in a table with options `look` and row bands of `band_size` rows.
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == nrows && nrows > 1;
let first_col = look.first_column && g == 0;
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
let size = band_size.clamp(1, 1000) as usize;
let row_band = look.banded_rows && !header;
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
let col_band = look.banded_columns && !first_col;
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
let mut bits = TablePart::WholeTable.bit();
for (on, p) in [
(col_band && odd_col, TablePart::Band1Vert),
(col_band && !odd_col, TablePart::Band2Vert),
(row_band && odd_row, TablePart::Band1Horz),
(row_band && !odd_row, TablePart::Band2Horz),
(first_col, TablePart::FirstCol),
(last_col, TablePart::LastCol),
(header, TablePart::FirstRow),
(total, TablePart::LastRow),
(header && last_col, TablePart::NeCell),
(header && first_col, TablePart::NwCell),
(total && last_col, TablePart::SeCell),
(total && first_col, TablePart::SwCell),
] {
if on {
bits |= p.bit();
}
}
PartSet(bits)
}
pub fn contains(self, p: TablePart) -> bool {
self.0 & p.bit() != 0
}
/// The parts, lowest precedence first.
pub fn iter(self) -> impl Iterator<Item = TablePart> {
TablePart::ALL.into_iter().filter(move |p| self.contains(*p))
}
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct PartFormat {
pub chr: CharProps,
/// Cell shading.
pub fill: Option<Rgb>,
/// Cell borders (the whole table's are the table borders).
pub borders: Option<Borders>,
/// Horizontal alignment of the cells' paragraphs (`w:pPr/w:jc`).
pub align: Option<Align>,
/// Vertical alignment in the cells (`w:tcPr/w:vAlign`).
pub v_align: Option<VAlign>,
}
impl PartFormat {
pub fn is_empty(&self) -> bool {
*self == PartFormat::default()
}
/// What `patch` sets wins; borders merge edge by edge.
pub fn overlay(&mut self, patch: &PartFormat) {
self.chr.overlay(&patch.chr);
self.fill = patch.fill.or(self.fill);
self.borders = match (self.borders, patch.borders) {
(Some(mut b), Some(p)) => {
b.overlay(&p);
Some(b)
}
(b, p) => p.or(b),
};
self.align = patch.align.or(self.align);
self.v_align = patch.v_align.or(self.v_align);
}
}
impl TableStyleParts {
@@ -289,125 +120,6 @@ impl TableStyleParts {
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
self.col_band_chr.overlay(&patch.col_band_chr);
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
for (k, f) in &patch.regions {
self.regions.entry(*k).or_default().overlay(f);
}
}
/// Part `p`'s formatting. The whole table's text formatting is the style's own
/// ([`Style::table_part`] adds it); its borders are the table borders.
pub fn part(&self, p: TablePart) -> PartFormat {
let extra = self.regions.get(&p).cloned().unwrap_or_default();
let named = |chr: &CharProps, fill: Option<Rgb>, borders: Option<Borders>| PartFormat {
chr: chr.clone(),
fill,
borders,
align: extra.align,
v_align: extra.v_align,
};
match p {
TablePart::WholeTable => {
PartFormat { chr: CharProps::default(), fill: self.fill, borders: self.borders, align: None, v_align: extra.v_align }
}
TablePart::FirstRow => named(&self.header_chr, self.header_fill, self.header_borders),
TablePart::LastRow => {
// The built-in top rule is the region's top edge unless its borders set one.
let borders = match (self.total_borders, self.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|t| Borders { top: Some(t), ..Borders::default() }),
};
named(&self.total_chr, self.total_fill, borders)
}
TablePart::FirstCol => named(&self.first_col_chr, self.first_col_fill, self.first_col_borders),
TablePart::LastCol => named(&self.last_col_chr, self.last_col_fill, self.last_col_borders),
TablePart::Band1Vert => named(&self.col_band_chr, self.col_band_fill, self.col_band_borders),
TablePart::Band1Horz => named(&self.band_chr, self.band_fill, self.band_borders),
_ => extra,
}
}
/// Replace part `p`'s formatting (for the whole table only its fill, borders and vertical
/// alignment: [`Style::set_table_part`] stores the rest).
pub fn set_part(&mut self, p: TablePart, f: PartFormat) {
let align_only = PartFormat { align: f.align, v_align: f.v_align, ..PartFormat::default() };
let PartFormat { chr, fill, borders, .. } = f.clone();
let rest = match p {
TablePart::WholeTable => {
self.fill = fill;
self.borders = borders;
PartFormat { v_align: f.v_align, ..PartFormat::default() }
}
TablePart::FirstRow => {
(self.header_chr, self.header_fill, self.header_borders) = (chr, fill, borders);
align_only
}
TablePart::LastRow => {
(self.total_chr, self.total_fill) = (chr, fill);
self.total_border_top = borders.and_then(|b| b.top);
self.total_borders = borders.map(|b| Borders { top: None, ..b }).filter(|b| *b != Borders::default());
align_only
}
TablePart::FirstCol => {
(self.first_col_chr, self.first_col_fill, self.first_col_borders) = (chr, fill, borders);
align_only
}
TablePart::LastCol => {
(self.last_col_chr, self.last_col_fill, self.last_col_borders) = (chr, fill, borders);
align_only
}
TablePart::Band1Vert => {
(self.col_band_chr, self.col_band_fill, self.col_band_borders) = (chr, fill, borders);
align_only
}
TablePart::Band1Horz => {
(self.band_chr, self.band_fill, self.band_borders) = (chr, fill, borders);
align_only
}
_ => f,
};
if rest.is_empty() {
self.regions.remove(&p);
} else {
self.regions.insert(p, rest);
}
}
}
impl Style {
/// Table style part `p` as this style alone sets it (the whole table's text formatting and
/// paragraph alignment are the style's own).
pub fn table_part(&self, p: TablePart) -> PartFormat {
let mut f = self.table.as_ref().map(|t| t.part(p)).unwrap_or_default();
if p == TablePart::WholeTable {
f.chr = self.chr.clone();
f.align = self.para.align;
}
f
}
/// Replace part `p` of this table style.
pub fn set_table_part(&mut self, p: TablePart, f: PartFormat) {
if p == TablePart::WholeTable {
self.chr = f.chr.clone();
self.para.align = f.align;
}
let mut t = self.table.take().unwrap_or_default();
t.set_part(p, f);
self.table = (t != TableStyleParts::default()).then_some(t);
}
}
impl TableStyleProps {
/// Part `p` with the based-on chain merged (see [`Style::table_part`]).
pub fn part(&self, p: TablePart) -> PartFormat {
let mut f = self.parts.part(p);
if p == TablePart::WholeTable {
f.chr = self.chr.clone();
f.align = self.para.align;
}
f
}
}
@@ -430,10 +142,6 @@ pub struct StyleSheet {
/// the application; nonempty `default_para` is always written after spacing edits.
pub default_para_present: bool,
pub styles: Vec<Style>,
/// The file's `w:latentStyles` (behaviour of built-in styles not defined in the document:
/// gallery, priority, visibility), as XML, written back as is.
#[serde(skip_serializing_if = "Option::is_none")]
pub latent_styles: Option<String>,
}
impl Default for StyleSheet {
@@ -460,19 +168,13 @@ fn color(c: Rgb) -> Option<TextColor> {
impl StyleSheet {
pub fn empty() -> Self {
StyleSheet {
default_chr: CharProps::default(),
default_para: ParaProps::default(),
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
}
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
}
/// The built-in style set used for new documents.
pub fn builtin() -> Self {
let mut s = StyleSheet {
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(1.0), lang: Some("en-US".into()), ..chr() },
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(0.0), lang: Some("en-US".into()), ..chr() },
default_para: ParaProps {
space_after: Some(8.0),
space_before: Some(0.0),
@@ -482,7 +184,6 @@ impl StyleSheet {
},
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
};
let mut add = |st: Style| s.styles.push(st);
add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() });
@@ -743,19 +444,6 @@ impl StyleSheet {
out.reverse();
out
}
/// Which indents (left, first line) of a paragraph that gets its list from style `id` the style
/// chain sets over the list level's own. Word applies the level's indents on top of the most
/// derived style that gives a list, so that style and the ones based on it win over the level,
/// and the styles it is based on don't (Heading 3 with its own left indent on Heading 2's
/// list). `(false, false)` when no style in the chain gives a list.
pub fn indents_over_list(&self, id: &str) -> (bool, bool) {
let chain = self.chain(id);
let Some(k) = chain.iter().rposition(|s| s.para.numbering.is_some()) else { return (false, false) };
let set = |v: Option<f32>, chars: Option<f32>| v.is_some() || chars.is_some_and(|c| c != 0.0);
chain.get(k..).unwrap_or_default().iter().fold((false, false), |(l, f), s| {
(l || set(s.para.indent_left, s.para.indent_left_chars), f || set(s.para.indent_first, s.para.indent_first_chars))
})
}
/// Table style `id` with its based-on chain merged, base styles first (`None` when there is no
/// such table style). Styles of other kinds in the chain are skipped.
pub fn table_style(&self, id: &str) -> Option<TableStyleProps> {
@@ -782,33 +470,6 @@ impl StyleSheet {
None => self.styles.push(st),
}
}
/// For a file from a producer that never writes `w:qFormat` (LibreOffice): give it Word's
/// usual Quick Styles gallery. Each built-in style whose id or name matches a quick style of
/// [`StyleSheet::builtin`] becomes quick (taking the built-in's priority when it has none), and
/// the quick built-ins the file lacks are added with their linked styles. Existing ids and
/// names are never overwritten. Callers use it only when no style in the file is quick.
pub fn add_default_gallery(&mut self) {
let builtin = StyleSheet::builtin();
for q in builtin.styles.iter().filter(|s| s.quick && s.kind != StyleKind::Table) {
let found =
self.styles.iter().position(|s| s.id == q.id).or_else(|| self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(&q.name)));
if let Some(st) = found.and_then(|i| self.styles.get_mut(i)) {
if st.builtin && st.kind == q.kind {
st.quick = true;
st.priority = st.priority.or(q.priority);
}
continue;
}
for sid in std::iter::once(q.id.as_str()).chain(q.linked.as_deref()) {
if let Some(b) = builtin.get(sid)
&& self.get(sid).is_none()
&& self.find(&b.name).is_none()
{
self.styles.push(b.clone());
}
}
}
}
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
/// retarget ([`crate::Document::restyle`]).
@@ -931,32 +592,6 @@ mod tests {
assert!(g.iter().any(|s| s.id == "Heading1"));
}
#[test]
fn default_gallery_marks_and_adds_quick_builtins() {
let b = StyleSheet::builtin();
let mut s = StyleSheet::empty();
let plain = |id: &str, name: &str| Style { id: id.into(), name: name.into(), builtin: true, ..Default::default() };
s.styles.push(plain("Normal", "Normal"));
s.styles.push(plain("Heading1", "heading 1"));
s.styles.push(Style { priority: Some(7), ..plain("Heading2", "Heading 2") });
s.styles.push(Style { builtin: false, ..plain("Heading3", "Heading 3") });
s.styles.push(Style { para: ParaProps { space_after: Some(99.0), ..Default::default() }, ..plain("Custom1", "Subtitle") });
s.add_default_gallery();
let ids: Vec<&str> = s.gallery().iter().map(|x| x.id.as_str()).collect();
for id in ["Normal", "NoSpacing", "Heading1", "Heading2", "Title"] {
assert!(ids.contains(&id), "{id} in {ids:?}");
}
let mut bare = StyleSheet::empty();
bare.add_default_gallery();
assert!(bare.get("Heading1").is_some_and(|x| x.quick) && bare.get("Title").is_some(), "missing quick builtins added");
assert_eq!(s.get("Heading2").and_then(|x| x.priority), Some(7), "file priority kept");
assert_eq!(s.get("Heading1").and_then(|x| x.priority), b.get("Heading1").and_then(|x| x.priority));
assert!(!s.get("Heading3").is_some_and(|x| x.quick), "custom style untouched");
assert!(s.get("Subtitle").is_none(), "a style with that name exists: not duplicated");
assert!(s.get("Custom1").is_some_and(|x| x.quick), "found by name only, built in: quick");
assert_eq!(s.get("Custom1").and_then(|x| x.para.space_after), Some(99.0));
}
#[test]
fn older_serialized_styles_keep_explicit_defaults() {
let mut value = serde_json::to_value(StyleSheet::empty()).unwrap();
-296
View File
@@ -1,296 +0,0 @@
//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to
//! standard Unicode characters, so text set in them shows the intended symbols when the font
//! isn't installed (they are proprietary and never bundled) and exports as real characters.
//!
//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own
//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code
//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text
//! never changes: the mapping is for display, layout and plain-text output only.
//!
//! Sources (Unicode's own published data; no font file was read):
//! - Symbol: the Adobe Symbol encoding table published by Unicode,
//! <https://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt> (codes it maps to the
//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU).
//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of
//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard,
//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052,
//! L2/11-344), checked against the Unicode Character Database names. The one character not
//! encoded (Wingdings 0xFF) has no entry.
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") {
Some(&SYMBOL)
} else if f.eq_ignore_ascii_case("wingdings") {
Some(&WINGDINGS)
} else if f.eq_ignore_ascii_case("wingdings 2") {
Some(&WINGDINGS_2)
} else if f.eq_ignore_ascii_case("wingdings 3") {
Some(&WINGDINGS_3)
} else if f.eq_ignore_ascii_case("webdings") {
Some(&WEBDINGS)
} else {
None
}
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
table(family).is_some()
}
/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the
/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD).
/// `None` for other fonts, other characters and codes with no Unicode equivalent.
pub fn symbol_char(family: &str, c: char) -> Option<char> {
let t = table(family)?;
let u = u32::from(c);
let code = match u {
0xF020..=0xF0FF => u - 0xF000,
// A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD).
0xAD => return None,
0x20..=0xFF => u,
_ => return None,
};
let i = usize::try_from(code.checked_sub(0x20)?).ok()?;
t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32)
}
/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode
/// equivalent; unchanged for other fonts.
pub fn map_text(family: &str, text: &str) -> String {
if !is_symbol_font(family) {
return text.to_string();
}
text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect()
}
const SYMBOL: [u32; 224] = [
0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20
0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30
0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38
0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40
0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48
0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50
0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58
0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60
0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68
0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70
0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98
0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0
0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8
0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0
0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8
0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0
0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8
0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0
0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8
0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8
0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WEBDINGS: [u32; 224] = [
0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20
0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28
0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30
0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38
0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40
0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48
0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50
0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58
0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60
0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68
0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70
0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78
0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80
0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88
0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90
0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98
0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0
0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8
0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0
0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8
0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0
0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8
0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0
0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8
0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0
0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8
0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0
0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8
];
const WINGDINGS: [u32; 224] = [
0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20
0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28
0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30
0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38
0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40
0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48
0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50
0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58
0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60
0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68
0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70
0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78
0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80
0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88
0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90
0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98
0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0
0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8
0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0
0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8
0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0
0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8
0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0
0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8
0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0
0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8
0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0
0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8
];
const WINGDINGS_2: [u32; 224] = [
0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20
0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28
0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30
0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38
0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40
0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48
0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50
0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58
0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60
0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68
0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70
0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78
0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80
0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88
0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90
0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98
0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0
0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8
0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0
0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8
0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0
0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8
0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0
0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8
0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0
0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8
0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0
0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WINGDINGS_3: [u32; 224] = [
0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20
0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28
0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30
0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38
0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40
0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48
0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50
0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58
0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60
0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68
0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70
0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78
0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80
0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88
0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90
0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98
0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0
0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8
0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0
0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8
0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0
0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8
0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0
0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8
0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0
0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8
0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_storage_forms_map_alike() {
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
assert!(is_symbol_font(f));
for code in 0x20u32..=0xFF {
let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue };
if code == 0xAD {
assert_eq!(symbol_char(f, plain), None);
continue;
}
assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}");
}
}
}
#[test]
fn symbol_uses_the_adobe_symbol_encoding() {
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
assert_eq!(symbol_char("symbol", 'a'), Some('α'));
assert_eq!(symbol_char("Symbol", 'm'), Some('μ'));
assert_eq!(symbol_char("Symbol", 'W'), Some('Ω'));
assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•'));
assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞'));
assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√'));
// The radical extender is private use in the Adobe table: no equivalent.
assert_eq!(symbol_char("Symbol", '\u{F060}'), None);
}
#[test]
fn wingdings_codes() {
assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'J'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'L'), Some('☹'));
assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓'));
assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}'));
assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼'));
assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪'));
assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖'));
assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}'));
// The one character Unicode didn't encode.
assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None);
}
#[test]
fn wingdings_2_3_and_webdings_codes() {
assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}'));
assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}'));
assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None);
assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠'));
assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None);
assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}'));
assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}'));
assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}'));
}
#[test]
fn other_fonts_and_characters_are_left_alone() {
assert!(!is_symbol_font("Arial"));
assert_eq!(symbol_char("Arial", '\u{F04A}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None);
assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F}'), None);
assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}");
assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}");
}
}
+1 -164
View File
@@ -16,51 +16,6 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let bul_id = d.numbering.add_list(ListKind::Bullet);
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p3 = Paragraph::with_text("Cherry", CharProps::default());
p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
let mut p4 = Paragraph::with_text("Date", CharProps::default());
p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))];
// Numbered items contribute 2 words each (label + text) = 4 words.
// Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words.
assert_eq!(d.word_count(), 6);
let plain = d.plain_text(StoryRef::Body);
assert_eq!(plain, "Apple\nBanana\nCherry\nDate");
}
#[test]
fn word_count_includes_numbered_labels_with_nothing_suffix() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default());
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))];
// Label "1." and text "Apple" must each be counted as a separate word (2 words),
// and label "2." and text "Banana Cherry" as 3 words (total: 5 words),
// even though LevelSuffix::Nothing produces no visible separator between label and text.
assert_eq!(d.word_count(), 5);
}
#[test]
fn sections_and_section_mut() {
let mut d = Document::from_text("a\nb\nc");
@@ -90,46 +45,6 @@ fn parts_and_media() {
assert_ne!(k1, k3);
}
#[test]
fn free_ids_skip_taken_ones_instead_of_overflowing() {
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8));
assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1));
// A file used the largest id: the lowest free one, not a wrapped 0 or a taken id.
assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3));
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2));
// Every id from `min` up taken: none, rather than a duplicate.
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None);
}
#[test]
fn parts_and_lists_after_the_largest_ids_take_free_ones() {
// A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them.
let mut d = Document::new();
for id in [0, 1, u32::MAX] {
let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))];
d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() });
}
let parts = d.parts.clone();
assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2));
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
let n = &mut d.numbering;
n.add_list(ListKind::Bullet);
n.add_list(ListKind::Bullet);
n.abstracts[1].id = u32::MAX;
n.nums[1].id = u32::MAX;
n.nums[1].abstract_id = u32::MAX;
let lists = n.clone();
let num = n.try_add_list(ListKind::Numbered).unwrap();
assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1)));
assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered)));
assert_eq!(n.restart(num), Some(3));
assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1));
assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..]));
}
#[test]
fn clone_is_shallow() {
let d = Document::from_text("a\nb");
@@ -281,84 +196,6 @@ fn prune_text_boxes_drops_only_unshown_ones() {
assert_eq!(d.prune_text_boxes(), 0);
}
/// `blocks` in the only cell of `levels` tables, each nested in the next.
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
(0..levels).fold(blocks, |inner, _| {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = inner;
vec![Arc::new(Block::Table(t))]
})
}
/// A paragraph showing text box story `story` (drawing id `id`).
fn box_para(story: u32, id: u32) -> Blocks {
let shape = InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: connector::ShapeExtra { id, ..Default::default() },
};
let mut p = Paragraph::with_text("cell", CharProps::default());
p.insert_object(0, shape, &CharProps::default()).unwrap();
vec![para_block(p)]
}
#[test]
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
// for unshown and the next edit pruned its story.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
d.body.extend(nested_tables(17, box_para(at_17, 0)));
d.body.extend(nested_tables(40, box_para(at_40, 7)));
d.body.extend(box_para(outer, 0));
assert_eq!(d.prune_text_boxes(), 1);
assert!(!d.parts.contains_key(&unshown));
let kept: Vec<u32> = d.parts.keys().copied().collect();
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
assert_eq!(d.prune_text_boxes(), 0);
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
let part = StoryRef::Part;
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
assert_eq!(d.next_shape_id(), 8);
}
#[test]
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
// A copy takes the box 17 tables down along, so pasting it into another document shows its
// text, not the other document's box that happens to have the same id.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
d.body.extend(nested_tables(17, box_para(deep, 0)));
let frag = d.body_fragment();
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
let mut e = Document::new();
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
assert_eq!(own, deep, "the same id in both documents");
e.body = box_para(own, 0);
let end = e.end_of(StoryRef::Body);
e.insert_fragment(&end, &frag).unwrap();
let mut shown = Vec::new();
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
assert_eq!(texts, vec!["its own box", "deep box"]);
}
#[test]
fn notes_in_text_box_follow_nested_boxes_once() {
let shape = |story| InlineObject::Shape {
@@ -391,7 +228,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
}
fn fs(instr: &str) -> InlineObject {
InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }
InlineObject::FieldStart { instr: instr.into(), locked: false }
}
#[test]
+9 -20
View File
@@ -59,8 +59,8 @@ impl WrapPolygon {
/// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight
/// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines,
/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Option<Vec<(f32, f32)>> {
/// freeforms).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>> {
use std::f32::consts::{FRAC_PI_2, PI, TAU};
let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 };
let (w, h) = (pos(w), pos(h));
@@ -73,12 +73,10 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
| ShapeKind::StraightConnector
| ShapeKind::ElbowConnector
| ShapeKind::CurvedConnector => return None,
ShapeKind::Ellipse | ShapeKind::FlowchartOr => {
(0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect()
}
ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(),
ShapeKind::RoundedRectangle => {
// Corner radius as drawn.
let r = crate::connector::corner_radius(corner, w, h);
// Corner radius as drawn: 16% of the shorter side.
let r = 0.16 * w.min(h);
let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) };
let mut pts = Vec::new();
for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] {
@@ -90,7 +88,6 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
pts
}
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
@@ -220,26 +217,18 @@ mod tests {
ShapeKind::Ellipse,
ShapeKind::RoundedRectangle,
ShapeKind::Triangle,
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::Star,
ShapeKind::Heart,
] {
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] {
let pts = shape_outline(kind, w, h, corner).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
}
let pts = shape_outline(kind, w, h).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
}
}
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none());
// The end of the bottom-right corner: in the frame's corner when the corner is square,
// 16% of the height (8 of 100 wide) in from it by default.
let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6];
assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0)));
assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None));
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0).is_none());
}
#[test]
-1
View File
@@ -14,7 +14,6 @@ wordcraft-geom = { workspace = true }
cfb = { workspace = true }
image = { workspace = true }
miniz_oxide = { workspace = true }
sha1 = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
-447
View File
@@ -1,447 +0,0 @@
//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7).
//!
//! Three schemes exist for `.doc`:
//!
//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and
//! the streams are XORed with a 16-byte array derived from the password (method 2).
//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key
//! per 512-byte block comes from MD5.
//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a
//! key size of 40-128 bits and a variable-size header.
//!
//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its
//! header, for RC4) and the whole Data stream are transformed. The password is checked against
//! the stored verifier before anything is decrypted, so a wrong password is reported and never
//! yields a garbled document. Everything is written from the public specifications.
use sha1::{Digest, Sha1};
use crate::DocbinError;
/// Bytes at the start of the WordDocument stream that are stored untransformed.
pub(crate) const FIB_PLAIN: usize = 68;
/// RC4 re-keys every this many bytes of a stream.
const BLOCK: usize = 512;
/// Longest password considered (the specifications' limit for the RC4 schemes).
const MAX_PASSWORD_CHARS: usize = 255;
/// XOR obfuscation uses at most this many single-byte password characters.
const MAX_XOR_PASSWORD: usize = 15;
/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`).
pub(crate) const F_ENCRYPTED: u16 = 0x0100;
pub(crate) const F_OBFUSCATED: u16 = 0x8000;
/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`.
const H_CRYPTO_API: u32 = 0x04;
const H_EXTERNAL: u32 = 0x10;
const H_AES: u32 = 0x20;
fn unsupported(what: &str) -> DocbinError {
DocbinError::Encryption(what.into())
}
// ---------------------------------------------------------------------------------------------
// Primitives
/// MD5 ([RFC 1321]); the RC4 scheme is defined on it.
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16,
23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it.
let k: Vec<u32> = (0..64u32).map(|i| (f64::from(i + 1).sin().abs() * 4_294_967_296.0) as u32).collect();
let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32);
let mut msg = Vec::with_capacity(data.len() + 72);
msg.extend_from_slice(data);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&(data.len() as u64).wrapping_mul(8).to_le_bytes());
for chunk in msg.as_chunks::<64>().0 {
let mut m = [0u32; 16];
let words = chunk.as_chunks::<4>().0;
for (w, b) in m.iter_mut().zip(words) {
*w = u32::from_le_bytes(*b);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0..64usize {
let (f, g) = match i / 16 {
0 => ((b & c) | (!b & d), i),
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
2 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | !d), (7 * i) % 16),
};
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
a = d;
d = c;
c = b;
b = b.wrapping_add(f.rotate_left(S[i]));
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
let mut out = [0u8; 16];
let (cells, _) = out.as_chunks_mut::<4>();
for (o, v) in cells.iter_mut().zip([a0, b0, c0, d0]) {
*o = v.to_le_bytes();
}
out
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// The RC4 stream cipher.
pub(crate) struct Rc4 {
s: [u8; 256],
i: u8,
j: u8,
}
impl Rc4 {
pub(crate) fn new(key: &[u8]) -> Rc4 {
let mut s = [0u8; 256];
for (n, v) in s.iter_mut().enumerate() {
*v = n as u8;
}
let kl = key.len().max(1);
let mut j = 0u8;
for i in 0..256usize {
let k = key.get(i % kl).copied().unwrap_or(0);
j = j.wrapping_add(s[i]).wrapping_add(k);
s.swap(i, usize::from(j));
}
Rc4 { s, i: 0, j: 0 }
}
fn next(&mut self) -> u8 {
self.i = self.i.wrapping_add(1);
self.j = self.j.wrapping_add(self.s[usize::from(self.i)]);
self.s.swap(usize::from(self.i), usize::from(self.j));
self.s[usize::from(self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]))]
}
pub(crate) fn apply(&mut self, data: &mut [u8]) {
for b in data {
*b ^= self.next();
}
}
}
fn utf16le(password: &str) -> Vec<u8> {
password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect()
}
fn u16_at(d: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
// ---------------------------------------------------------------------------------------------
// XOR obfuscation (method 1 key/verifier pieces, method 2 array and transform)
const PAD_ARRAY: [u8; 15] = [0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00];
const INITIAL_CODE: [u16; 15] =
[0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3];
const XOR_MATRIX: [u16; 105] = [
0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A,
0x16B4, 0x2D68, 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45,
0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4,
0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483,
0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6,
0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4,
];
/// `CreateXorKey_Method1`. `pw` is 1 to 15 bytes.
fn xor_key(pw: &[u8]) -> u16 {
let mut key = INITIAL_CODE.get(pw.len().wrapping_sub(1)).copied().unwrap_or(0);
let mut current = 0x68i32;
for &ch in pw.iter().rev() {
let mut ch = ch;
for _ in 0..7 {
if ch & 0x40 != 0
&& let Some(m) = usize::try_from(current).ok().and_then(|c| XOR_MATRIX.get(c))
{
key ^= m;
}
ch <<= 1;
current -= 1;
}
}
key
}
/// `CreatePasswordVerifier_Method1`.
fn verifier_method1(pw: &[u8]) -> u16 {
let mut v = 0u16;
for &b in pw.iter().rev().chain(std::iter::once(&(pw.len() as u8))) {
let i1 = u16::from(v & 0x4000 != 0);
let i2 = v.wrapping_mul(2) & 0x7FFF;
v = (i1 | i2) ^ u16::from(b);
}
v ^ 0xCE4B
}
/// `CreatePasswordVerifier_Method2`: the 32-bit value stored in `FibBase.lKey`.
pub(crate) fn xor_verifier(pw: &[u8]) -> u32 {
(u32::from(xor_key(pw)) << 16) | u32::from(verifier_method1(pw))
}
/// `CreateXorArray_Method2`.
pub(crate) fn xor_array(pw: &[u8]) -> [u8; 16] {
let key = (xor_verifier(pw) >> 16) as u16;
let (high, low) = ((key >> 8) as u8, key as u8);
let mut arr = [0u8; 16];
for (i, slot) in arr.iter_mut().enumerate() {
*slot = pw.get(i).copied().unwrap_or_else(|| PAD_ARRAY.get(i.saturating_sub(pw.len())).copied().unwrap_or(0));
}
let (pairs, _) = arr.as_chunks_mut::<2>();
for pair in pairs {
pair[0] = (pair[0] ^ low).rotate_right(1);
pair[1] = (pair[1] ^ high).rotate_right(1);
}
arr
}
/// The single-byte forms of a password the XOR scheme tries: the system-codepage form (Latin-1
/// here) and the "low byte, or high byte when the low one is zero" form. Each is cut to 15.
fn xor_candidates(password: &str) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = Vec::new();
let latin: Option<Vec<u8>> = password.chars().take(MAX_XOR_PASSWORD).map(|c| u8::try_from(u32::from(c)).ok()).collect();
if let Some(l) = latin {
out.push(l);
}
let folded: Vec<u8> = password.encode_utf16().take(MAX_XOR_PASSWORD).map(|u| if u & 0xFF == 0 { (u >> 8) as u8 } else { u as u8 }).collect();
if !out.contains(&folded) {
out.push(folded);
}
out.retain(|p| !p.is_empty());
out
}
// ---------------------------------------------------------------------------------------------
// The cipher
/// How the streams of one file are transformed, once the password has been verified.
pub(crate) enum Cipher {
Xor { array: [u8; 16] },
Rc4 { base: Vec<u8>, crypto_api: bool, key_len: usize, table_skip: usize },
}
impl Cipher {
/// Check `password` against the file's verifier and set up the cipher. `flags` and `lkey` come
/// from the FIB base, `table` is the (still encrypted) Table stream.
pub(crate) fn open(flags: u16, lkey: u32, table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
if flags & F_OBFUSCATED != 0 {
for pw in xor_candidates(password) {
if xor_verifier(&pw) == lkey {
return Ok(Cipher::Xor { array: xor_array(&pw) });
}
}
return Err(DocbinError::WrongPassword);
}
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let major = u16_at(table, 0).ok_or_else(short)?;
let minor = u16_at(table, 2).ok_or_else(short)?;
match (major, minor) {
(1, 1) => Self::open_rc4(table, password),
(2..=4, 2) => Self::open_crypto_api(table, password),
_ => Err(unsupported(&format!("unknown encryption version {major}.{minor}"))),
}
}
fn open_rc4(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let salt = table.get(4..20).ok_or_else(short)?;
let enc_verifier = table.get(20..36).ok_or_else(short)?;
let enc_hash = table.get(36..52).ok_or_else(short)?;
let h0 = md5(&utf16le(password));
let mut buf = Vec::with_capacity(336);
for _ in 0..16 {
buf.extend_from_slice(&h0[..5]);
buf.extend_from_slice(salt);
}
let base = md5(&buf)[..5].to_vec();
let cipher = Cipher::Rc4 { base, crypto_api: false, key_len: 16, table_skip: 52 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = [0u8; 16];
verifier.copy_from_slice(enc_verifier);
let mut hash = [0u8; 16];
hash.copy_from_slice(enc_hash);
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if md5(&verifier) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
fn open_crypto_api(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let hflags = u32_at(table, 4).ok_or_else(short)?;
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
if hflags & H_AES != 0 {
return Err(unsupported("AES-encrypted .doc files are not supported"));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
}
let hsize = usize::try_from(u32_at(table, 8).ok_or_else(short)?).unwrap_or(usize::MAX);
// EncryptionHeader: flags, sizeExtra, algId, algIdHash, keySize, providerType, 2 reserved
// words and the CSP name; it starts at byte 12.
if hsize < 32 || hsize > table.len() {
return Err(DocbinError::Malformed("the encryption header has a bad size".into()));
}
let alg_id = u32_at(table, 20).ok_or_else(short)?;
let alg_hash = u32_at(table, 24).ok_or_else(short)?;
if !matches!(alg_id, 0 | 0x6801) {
return Err(unsupported(&format!("encryption algorithm 0x{alg_id:X}")));
}
if !matches!(alg_hash, 0 | 0x8004) {
return Err(unsupported(&format!("hash algorithm 0x{alg_hash:X}")));
}
let mut bits = u32_at(table, 28).ok_or_else(short)?;
if bits == 0 {
bits = 40;
}
if !(40..=128).contains(&bits) || bits % 8 != 0 {
return Err(unsupported(&format!("a {bits}-bit key")));
}
let key_len = (bits / 8) as usize;
// EncryptionVerifier: saltSize, salt[16], encryptedVerifier[16], verifierHashSize,
// encryptedVerifierHash[20].
let v = 12 + hsize;
if u32_at(table, v) != Some(16) {
return Err(DocbinError::Malformed("the encryption salt has a bad size".into()));
}
let salt = table.get(v + 4..v + 20).ok_or_else(short)?;
let enc_verifier = table.get(v + 20..v + 36).ok_or_else(short)?;
if u32_at(table, v + 36) != Some(20) {
return Err(unsupported("a verifier hash that is not SHA-1"));
}
let enc_hash = table.get(v + 40..v + 60).ok_or_else(short)?;
let mut pw = utf16le(password);
let base = sha1(&[salt, &pw]);
pw.fill(0);
let cipher = Cipher::Rc4 { base, crypto_api: true, key_len, table_skip: v + 60 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = enc_verifier.to_vec();
let mut hash = enc_hash.to_vec();
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if sha1(&[&verifier]) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
/// The RC4 key of stream block `block`.
fn block_key(&self, block: u32) -> Vec<u8> {
match self {
Cipher::Xor { .. } => Vec::new(),
Cipher::Rc4 { base, crypto_api: false, .. } => md5(&[base.as_slice(), &block.to_le_bytes()].concat()).to_vec(),
Cipher::Rc4 { base, crypto_api: true, key_len, .. } => {
let mut k = sha1(&[base, &block.to_le_bytes()]);
k.truncate(*key_len);
// A 40-bit key is zero-padded to 128 bits.
if *key_len == 5 {
k.resize(16, 0);
}
k
}
}
}
/// Decrypt `data` in place; its first `skip` bytes are stored untransformed.
fn decrypt(&self, data: &mut [u8], skip: usize) {
match self {
Cipher::Xor { array } => {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
let a = array[i % 16];
// Zero bytes, and bytes equal to the array element, were left as they were.
if *b != 0 && *b != a {
*b ^= a;
}
}
}
Cipher::Rc4 { .. } => {
for (n, chunk) in data.chunks_mut(BLOCK).enumerate() {
let Ok(block) = u32::try_from(n) else { return };
let mut rc4 = Rc4::new(&self.block_key(block));
let start = n * BLOCK;
// The keystream runs over the untransformed bytes too.
let plain = skip.saturating_sub(start).min(chunk.len());
let head = chunk.get(..plain).map(<[u8]>::to_vec).unwrap_or_default();
rc4.apply(chunk);
if let Some(dst) = chunk.get_mut(..plain) {
dst.copy_from_slice(&head);
}
}
}
}
}
pub(crate) fn decrypt_word(&self, data: &mut [u8]) {
self.decrypt(data, FIB_PLAIN);
}
pub(crate) fn decrypt_table(&self, data: &mut [u8]) {
let skip = match self {
Cipher::Xor { .. } => 0,
Cipher::Rc4 { table_skip, .. } => *table_skip,
};
self.decrypt(data, skip);
}
pub(crate) fn decrypt_data(&self, data: &mut [u8]) {
self.decrypt(data, 0);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn md5_matches_the_rfc_1321_vectors() {
assert_eq!(hex(&md5(b"")), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(hex(&md5(b"a")), "0cc175b9c0f1b6a831c399e269772661");
assert_eq!(hex(&md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(hex(&md5(b"message digest")), "f96b697d7cb7938d525a2f31aaf161d0");
assert_eq!(
hex(&md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890")),
"57edf4a22be3c955ac49da2e2107b67a"
);
}
#[test]
fn rc4_matches_a_published_vector() {
let mut d = b"Plaintext".to_vec();
Rc4::new(b"Key").apply(&mut d);
assert_eq!(hex(&d), "bbf316e8d940af0ad3");
}
#[test]
fn xor_candidates_cut_to_fifteen_and_skip_empty() {
assert!(xor_candidates("").is_empty());
assert_eq!(xor_candidates("abcdefghijklmnopqrst")[0].len(), 15);
// Not representable as single bytes: only the folded form remains.
assert_eq!(xor_candidates("\u{4e2d}").len(), 1);
}
}
+2 -9
View File
@@ -37,22 +37,16 @@ fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
/// relative to the text box subdocument).
///
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
let Some(pair) = pair else { return Vec::new() };
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
let mut out = Vec::new();
let mut kept_to = 0u32;
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
if end > *cp && *cp >= kept_to && lid != 0 {
if end > *cp && lid != 0 {
out.push((lid, (*cp, end)));
kept_to = end;
}
}
out
@@ -186,8 +180,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
2 => ShapeKind::RoundedRectangle,
3 => ShapeKind::Ellipse,
4 => ShapeKind::Diamond,
5 => ShapeKind::Triangle,
6 => ShapeKind::RightTriangle,
5 | 6 => ShapeKind::Triangle,
12 | 187 => ShapeKind::Star,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
20 | 32 => ShapeKind::Line,
+2 -25
View File
@@ -80,33 +80,10 @@ impl SubDocs {
}
}
/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Protection {
pub(crate) flags: u16,
/// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream.
pub(crate) lkey: u32,
pub(crate) which_tbl_stm: bool,
}
/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are
/// stored in the clear; this reads nothing else. `None` for anything else, including streams that
/// are too short or have a foreign magic (the full parse reports those).
pub(crate) fn peek_encryption(word: &[u8]) -> Option<Protection> {
if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() {
return None;
}
let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]);
if flags & crate::crypt::F_ENCRYPTED == 0 {
return None;
}
let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]);
Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 })
}
/// The parsed FIB.
#[derive(Clone, Debug)]
pub(crate) struct Fib {
pub(crate) encrypted: bool,
/// Selects 1Table (true) or 0Table (false).
pub(crate) which_tbl_stm: bool,
pub(crate) ccp: SubDocs,
@@ -180,6 +157,6 @@ impl Fib {
pairs.push(u32le(blob_off + i * 4));
}
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
Ok(Fib { encrypted: flags & 0x0100 != 0, which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
}
}
+1 -53
View File
@@ -1,7 +1,7 @@
//! Sprms → model properties, and style-chain resolution.
use wordcraft_doc::Rgb;
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TextColor, Underline, VertAlign};
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
use crate::sprm::{self, Prl, Toggle};
@@ -187,63 +187,11 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
}
}
/// Custom tab stops from `sprmPChgTabsPapx` / `sprmPChgTabs` ([MS-DOC] PChgTabsPapxOperand,
/// PChgTabsOperand): the stops removed become `Clear` stops (they hide inherited ones) and the
/// stops added carry their alignment and leader (TBD: jc in bits 0–2, tlc in bits 3–5).
fn change_tabs(p: &mut ParaProps, prl: &Prl) {
let op = prl.operand;
let i16_at = |i: usize| op.get(i..i + 2).map(|b| f32::from(i16::from_le_bytes([b[0], b[1]])) / 20.0);
// Both operands start with cb; sprmPChgTabs's removals also carry a "close" distance each.
let close = prl.op == sprm::P_CHG_TABS;
let mut at = 1;
let Some(&del) = op.get(at) else { return };
let del = usize::from(del.min(64));
at += 1;
let mut removed = Vec::with_capacity(del);
for k in 0..del {
if let Some(x) = i16_at(at + 2 * k) {
removed.push(x);
}
}
at += 2 * del * if close { 2 } else { 1 };
let Some(&add) = op.get(at) else { return };
let add = usize::from(add.min(64));
at += 1;
let mut tabs = p.tabs.take().unwrap_or_default();
// Removed within 25 twips (1.25 pt), as the format says.
tabs.retain(|t| !removed.iter().any(|x| (t.pos - x).abs() <= 1.25));
for x in removed {
tabs.push(TabStop { pos: x, align: TabAlign::Clear, leader: TabLeader::None });
}
for k in 0..add {
let (Some(pos), Some(&tbd)) = (i16_at(at + 2 * k), op.get(at + 2 * add + k)) else { break };
let align = match tbd & 7 {
1 => TabAlign::Center,
2 => TabAlign::Right,
3 => TabAlign::Decimal,
4 => TabAlign::Bar,
_ => TabAlign::Left,
};
let leader = match (tbd >> 3) & 7 {
1 => TabLeader::Dot,
2 => TabLeader::Hyphen,
3 | 4 => TabLeader::Underscore,
5 => TabLeader::MiddleDot,
_ => TabLeader::None,
};
tabs.retain(|t| (t.pos - pos).abs() > 0.05);
tabs.push(TabStop { pos, align, leader });
}
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
p.tabs = Some(tabs);
}
/// Apply a paragraph-property sprm.
pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0);
let s16 = |prl: &Prl| -> Option<i16> { u16_of(prl).map(|v| v as i16) };
match prl.op {
sprm::P_CHG_TABS | sprm::P_CHG_TABS_PAPX => change_tabs(p, prl),
P_JC | P_JC80 => {
p.align = match prl.operand.first().copied().unwrap_or(0) {
1 => Some(Align::Center),
+20 -82
View File
@@ -8,14 +8,12 @@
//! capped), so hostile input yields an error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crypt;
mod drawing;
mod fib;
mod fkp;
mod fmt;
mod list;
mod media;
mod metadata;
mod notes;
mod officeart;
mod piece;
@@ -45,10 +43,6 @@ use table::ParaOut;
const MAX_STREAM: u64 = 1 << 30;
/// Most header/footer parts we create (matches the docx reader's cap).
const MAX_PARTS: usize = 50_000;
/// Hard ceiling on characters materialised across every story walk of one document (body,
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
@@ -64,16 +58,9 @@ pub enum DocbinError {
/// The compound file is readable but is not a Word 97-2003 document.
#[error("not a Word 97-2003 document: {0}")]
NotWord(String),
/// The document is password-protected and no password was given.
#[error("this document is protected with a password")]
PasswordRequired,
/// The password does not open the document.
#[error("the password is incorrect")]
WrongPassword,
/// The document is encrypted in a way WordCraft can't read (AES, an external provider, an
/// unknown header).
#[error("encrypted document: {0}")]
Encryption(String),
/// The document is password-protected (XOR obfuscation or RC4 encryption).
#[error("encrypted or password-protected .doc files are not supported")]
Encrypted,
/// The input exceeds a safety limit (stream size, piece count…).
#[error("limit exceeded: {0}")]
Limit(String),
@@ -82,51 +69,25 @@ pub enum DocbinError {
Malformed(String),
}
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`].
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
read_with_password(bytes, None)
}
/// [`read`] for a file that may be password-protected: it is checked against the file's stored
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
let word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
}
let enc = fib::peek_encryption(&word);
let fib = fib::Fib::parse(&word)?;
if fib.encrypted {
return Err(DocbinError::Encrypted);
}
// The FIB names the Table stream (0Table or 1Table); fall back to whichever exists,
// since files written by other writers sometimes carry the wrong bit.
let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm };
let named = if which { "1Table" } else { "0Table" };
let other = if which { "0Table" } else { "1Table" };
let mut table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
let named = if fib.which_tbl_stm { "1Table" } else { "0Table" };
let other = if fib.which_tbl_stm { "0Table" } else { "1Table" };
let table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
if table.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len())));
}
let mut data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
if let Some(e) = enc {
// Verify the password first; only then is anything decrypted.
let password = password.ok_or(DocbinError::PasswordRequired)?;
let cipher = crypt::Cipher::open(e.flags, e.lkey, &table, password)?;
cipher.decrypt_word(&mut word);
cipher.decrypt_table(&mut table);
cipher.decrypt_data(&mut data);
}
let fib = fib::Fib::parse(&word)?;
// The piece table locates every character in the WordDocument stream.
let (fc_clx, lcb_clx) =
@@ -148,10 +109,12 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
None => Bins::parse(&[], 0, 0)?,
};
let mut doc = Document::new();
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
let data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
let mut doc = Document::new();
doc.body.clear();
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
doc.numbering = list::parse(&table, &fib, &fonts);
@@ -166,12 +129,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
let mut pics = media::Pictures::default();
// Every materialised character comes from at least one byte of the WordDocument stream, so a
// legitimate document's total text (body plus every header, footnote and text box, each read
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
// many times can, so the walk is held to twice the stream's size — generous for real files,
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let ctx = WalkCtx {
word: &word,
@@ -185,8 +142,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
notes: &[],
marks: &[],
drawn: &[],
sect_ends: &[],
budget: &budget,
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
@@ -260,9 +215,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// Sorted so the main walk finds each reference by binary search.
note_links.sort_by_key(|(cp, _, _)| *cp);
let marks = notes::parse_bookmarks(&table, &fib);
let mut sect_ends: Vec<u32> = sects.iter().map(|s| s.end_cp).collect();
sect_ends.sort_unstable();
let ctx = WalkCtx { notes: &note_links, marks: &marks, sect_ends: &sect_ends, ..ctx };
let ctx = WalkCtx { notes: &note_links, marks: &marks, ..ctx };
// Sections: each section's properties attach to the paragraph that ends it (its story of
// header/footer parts included); the section reaching the end of the text is the final one.
@@ -324,13 +277,6 @@ struct WalkCtx<'a> {
marks: &'a [(u32, notes::Mark)],
/// Floating shapes and pictures by anchor CP (absolute), sorted.
drawn: &'a [(u32, InlineObject)],
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
/// each ends the section's last paragraph, it isn't a page break inside it.
sect_ends: &'a [u32],
/// Characters still allowed across every story walk of this document. Story ranges (headers,
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -404,16 +350,8 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
continue;
}
};
// Every real character counts against the document-wide budget; once it is spent, stop
// (a hostile file with thousands of story ranges over the same text can't multiply on).
if ctx.budget.get() == 0 {
break;
}
ctx.budget.set(ctx.budget.get() - 1);
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
'\r' | '\u{7}' => {
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -575,7 +513,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
return;
}
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false });
}
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
+1 -2
View File
@@ -85,7 +85,6 @@ pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
id: i as u32,
name: None,
levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(),
..Default::default()
});
}
if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO)
@@ -225,7 +224,7 @@ fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Number
}
}
let id = numbering.abstracts.len() as u32;
numbering.abstracts.push(AbstractNum { id, name: None, levels, ..Default::default() });
numbering.abstracts.push(AbstractNum { id, name: None, levels });
id
};
numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() });
+19 -32
View File
@@ -19,13 +19,12 @@ const MAX_PICTURES: usize = 5_000;
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of
/// the drawing layer's BLIP store are shared the same way, by store index.
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the
/// drawing layer's BLIP store are shared the same way, by store index.
pub(crate) struct Pictures {
/// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap,
/// not retried).
by_fc: HashMap<u32, Option<Inline>>,
/// fc_pic → the inline picture already materialised.
by_fc: HashMap<u32, Inline>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// The media entries, merged into the document at the end.
@@ -69,34 +68,24 @@ impl Pictures {
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return hit.clone();
return Some(hit.clone());
}
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic.clone());
pic
}
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
/// the byte cap, and nothing at all once it is used up.
fn read(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
let room = self.limit.saturating_sub(self.total);
if room == 0 {
return None;
}
match locate(data, fc_pic, room)? {
let pic = match locate(data, fc_pic)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
let bytes = data.get(pic.range.clone())?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
}
Found::Shape { w, h, blip, props } => {
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
Inline { key, w, h, crop: props.crop, alt: props.alt }
}
}
};
self.by_fc.insert(fc_pic, pic.clone());
Some(pic)
}
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
@@ -159,9 +148,8 @@ enum Found {
const MM_SHAPE: u16 = 0x0064;
const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
/// a shape's picture is decoded only up to `room` bytes.
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData).
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
@@ -180,7 +168,7 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
let (blip, props) = inline_shape(pic.get(at..end)?, data)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
@@ -197,15 +185,14 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
}
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
/// the BLIP decodes to.
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream).
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, room)?;
let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?;
Some((blip, sp.props))
}
-114
View File
@@ -1,114 +0,0 @@
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
use std::io::{Read, Seek};
use wordcraft_doc::CoreProps;
const MAX_STREAM: u64 = 2 << 20;
const MAX_STRING: usize = 64 << 10;
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
if stream.len() > MAX_STREAM {
return None;
}
let mut bytes = Vec::new();
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_STREAM {
return None;
}
parse(&bytes)
}
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
}
fn parse(bytes: &[u8]) -> Option<CoreProps> {
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? != 1 {
return None;
}
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
return None;
}
let offset = u32_at(bytes, 44)? as usize;
if offset < 48 || !offset.is_multiple_of(4) {
return None;
}
let size = u32_at(bytes, offset)? as usize;
let set = bytes.get(offset..offset.checked_add(size)?)?;
let count = u32_at(set, 4)? as usize;
if count > 4096 {
return None;
}
let table_end = 8 + count * 8;
let table = set.get(8..table_end)?;
// Offsets are relative to the property set. Bound each value by the next property so a
// forged string length cannot read another property's bytes as part of the title.
let property = |id: u32| -> Option<&[u8]> {
let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice();
let start = u32_at(entry, 4)? as usize;
if start < table_end || !start.is_multiple_of(4) {
return None;
}
let end = table
.as_chunks::<8>()
.0
.iter()
.filter_map(|e| u32_at(e, 4))
.map(|n| n as usize)
.filter(|n| *n > start)
.min()
.unwrap_or(set.len())
.min(set.len());
set.get(start..end)
};
let cp = property(1)?;
if u32_at(cp, 0)? != 2 {
return None;
} // VT_I2
let codepage = u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?);
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
Some(CoreProps {
title: get(2),
subject: get(3),
creator: get(4),
keywords: get(5),
description: get(6),
last_modified_by: get(8),
revision: get(9).parse().unwrap_or_default(),
..Default::default()
})
}
fn text(value: &[u8], codepage: u16) -> Option<String> {
let kind = u32_at(value, 0)?;
let count = u32_at(value, 4)? as usize;
let size = match kind {
30 => count, // CodePageString length is in bytes, even for code page 1200.
31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units.
_ => return None,
};
if size > MAX_STRING {
return None;
}
let bytes = value.get(8..8usize.checked_add(size)?)?;
if kind == 31 || codepage == 1200 {
if !bytes.len().is_multiple_of(2) {
return None;
}
let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0);
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
} else {
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
if codepage == 65001 {
Some(String::from_utf8_lossy(bytes).into_owned())
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
} else {
None
}
}
}
+2 -10
View File
@@ -7,11 +7,6 @@ const MAX: usize = 10_000;
/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices
/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the
/// note subdocument.
///
/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table
/// that steps back would make many notes read the same text again (each one materialised
/// as its own part), so a story starting before the end of the last one kept is dropped:
/// every subdocument CP becomes at most one note.
pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> {
let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default();
@@ -19,15 +14,12 @@ pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize,
return Vec::new();
}
let mut out = Vec::new();
let mut kept_to = 0u32;
// The reference PLC's aFtnIdx order matches the story order; pair them by index.
for (i, &cp) in refs.iter().enumerate() {
if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1))
if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1))
&& b > a
&& a >= kept_to
{
out.push((cp, (a, b)));
kept_to = b;
out.push((cp, (*a, *b)));
}
}
out
+7 -36
View File
@@ -6,9 +6,6 @@
//! and record counts are capped, and decompressed metafiles are size-limited.
use std::collections::HashMap;
use std::io::Write;
use image::ImageEncoder;
use wordcraft_doc::props::Rgb;
@@ -141,7 +138,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
_ => dib_to_png(data, limit),
_ => dib_to_png(data),
}
}
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
@@ -178,7 +175,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
fn dib_to_png(dib: &[u8]) -> Option<Blip> {
let header = u32_at(dib, 0)? as usize;
if !(12..=124).contains(&header) {
return None;
@@ -196,7 +193,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
// BI_BITFIELDS masks follow a 40-byte header.
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
let file_size = 14usize.checked_add(dib.len())?;
let mut bmp = Vec::with_capacity(file_size);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
@@ -208,43 +205,17 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DIB_SIDE);
limits.max_image_height = Some(MAX_DIB_SIDE);
limits.max_alloc = Some((256 << 20).min(limit as u64));
limits.max_alloc = Some(256 << 20);
reader.limits(limits);
let mut png = PictureWriter { bytes: Vec::new(), limit };
let mut png = Vec::new();
match reader.decode() {
Ok(img)
if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() =>
{
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
Some(Blip { bytes: png, ext: "png", placeholder: false })
}
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
}
}
/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow.
struct PictureWriter {
bytes: Vec<u8>,
limit: usize,
}
impl Write for PictureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget"));
};
if end > self.bytes.capacity() {
let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit);
self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?;
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Shapes.
+1 -9
View File
@@ -171,11 +171,6 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) {
/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns
/// six stories in the order even header, odd (default) header, even footer, odd (default)
/// footer, first header, first footer. The CPs are relative to the header subdocument.
///
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
/// the end of an earlier one (a hostile table pointing many sections at the same text, each
/// read into its own part) is left empty, like a slot linked to the previous section's:
/// every subdocument CP becomes at most one story.
pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() };
let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
@@ -183,12 +178,9 @@ pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1);
let cp_at = |i: usize| -> Option<u32> { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
let mut stories = Vec::with_capacity(n);
let mut kept_to = 0u32;
for i in 0..n.saturating_sub(1) {
if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) {
let b = if a < kept_to { a } else { b.max(a) };
kept_to = kept_to.max(b);
stories.push((a, b));
stories.push((a, b.max(a)));
}
}
stories
+3 -14
View File
@@ -1,10 +1,8 @@
//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose
//! size comes from the opcode's `spra` field (with two variable-length exceptions).
/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand).
pub(crate) const P_CHG_TABS: u16 = 0xC615;
/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand).
pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D;
/// `sprmPChgTabs`: custom tab stops ([MS-DOC] §2.6.29 — handled in a later pass).
const P_CHG_TABS: u16 = 0xC615;
/// `sprmTDefTable`: table cell borders and widths.
const T_DEF_TABLE: u16 = 0xD608;
@@ -34,16 +32,7 @@ pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator<Item = Prl<'_>> {
let after = &rest[2..];
// Operand size per spra, with the exceptions whose size byte lies.
let len = match (op >> 13, op) {
// PChgTabsOperand.cb is one byte, the size after it; 255 means "work it out" from the
// two counts: cb, cTabs, 4 bytes per removed stop, cTabs, 3 bytes per added stop.
(_, P_CHG_TABS) => match after.first().copied() {
Some(255) => after.get(1).and_then(|&del| {
let add = after.get(2 + 4 * del as usize).copied()?;
Some(1 + 1 + 4 * del as usize + 1 + 3 * add as usize)
}),
Some(cb) => Some(1 + cb as usize),
None => None,
},
(_, P_CHG_TABS) => after.get(..2).map(|b| 2 + u16::from_le_bytes([b[0], b[1]]) as usize),
// TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the
// operand is one byte longer than cb.
(_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize),
+24 -90
View File
@@ -28,15 +28,6 @@ const T_TABLE_BORDERS_80: u16 = 0xD605;
const T_ROW_HEIGHT: u16 = 0x9407;
const T_DEF_TABLE: u16 = 0xD608;
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
const T_DEF_TABLE_SHD: u16 = 0xD612;
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
/// ranges, which Word 2007+ writes next to the above.
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
const T_CANT_SPLIT_90: u16 = 0x3403;
const T_MERGE: u16 = 0x5624;
const T_VERT_MERGE: u16 = 0xD62B;
@@ -58,10 +49,8 @@ pub(crate) struct RowInfo {
pub(crate) cell_mark: bool,
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
pub(crate) depth: u32,
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
pub(crate) edges: Vec<f32>,
/// Column widths (points) from `sprmTDefTable`.
pub(crate) grid: Vec<f32>,
height: Option<f32>,
height_rule: HeightRule,
header: bool,
@@ -73,9 +62,7 @@ pub(crate) struct RowInfo {
merges: Vec<(u8, u8)>,
/// Per-cell overrides keyed by (pre-merge) cell index.
vmerge: Vec<(u8, VMerge)>,
/// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an
/// automatic `Shd` (`None`) clears it.
shading: Vec<(u8, Option<Rgb>)>,
shading: Vec<(u8, Rgb)>,
widths: Vec<(u8, f32)>,
valign: Vec<(u8, VAlign)>,
}
@@ -87,12 +74,6 @@ impl RowInfo {
pub(crate) fn height(&self) -> Option<f32> {
self.height
}
/// The fill cell `i` ends up with (the last shading entry for it wins), for tests.
#[cfg(test)]
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
}
}
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
@@ -106,7 +87,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0,
P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0,
P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32,
T_DEF_TABLE => r.edges = def_table_edges(prl.operand),
T_DEF_TABLE => r.grid = def_table_grid(prl.operand),
T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0,
T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0,
T_ROW_HEIGHT => {
@@ -154,8 +135,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb)
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
{
for c in *a..(*b).min(MAX_COLS as u8) {
r.shading.push((c, Some(rgb)));
for c in *a..*b {
r.shading.push((c, rgb));
}
}
}
@@ -163,29 +144,10 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let cells = prl.operand.get(1..).unwrap_or(&[]);
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
if let Some(rgb) = shd80_rgb(b) {
r.shading.push((i as u8, Some(rgb)));
r.shading.push((i as u8, rgb));
}
}
}
T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => {
// Each array covers its own cell range (0-21, 22-43, 44-62); extra entries
// are ignored so an over-long array can't override the next range.
let (first, lim) = match prl.op {
T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44),
T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS),
_ => (0, 22),
};
let cells = prl.operand.get(1..).unwrap_or(&[]);
let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD);
for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() {
// In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style
// shades it", so it leaves any earlier fill alone.
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
continue;
}
r.shading.push(((first + i) as u8, shd_rgb(b)));
}
}
T_CELL_WIDTH => {
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
if let [a, b, fts, w0, w1, ..] = prl.operand
@@ -259,11 +221,11 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
c.props.width = Some(*w);
}
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
c.props.shading = *s;
if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) {
c.props.shading = Some(*s);
}
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
c.props.valign = Some(*v);
c.props.valign = *v;
}
}
for &(a, b) in info.merges.iter().take(MAX_COLS) {
@@ -288,7 +250,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
height_rule: info.height_rule,
header: info.header,
cant_split: info.cant_split,
..Default::default()
fmt_change: None,
},
cells,
controls: Default::default(),
@@ -333,13 +295,6 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if !is_table {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
// Table props are per-table (first row carrying the sprm wins within the
// table): reset the accumulator so one table's borders, align, indent and
// cell margins never leak into the next (the repro's first table wrote
// Word's default 108-twip gap, and every later table — including the
// change history with its own 40-twip gap — inherited 5.4 pt margins).
props = TableProps::default();
open = false;
depth_seen = 0;
}
@@ -350,9 +305,8 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
open = true;
}
depth_seen = depth_seen.max(info.depth);
if grid.is_empty() {
// Column widths from the table's first `sprmTDefTable`.
grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect();
if !info.grid.is_empty() && grid.is_empty() {
grid = info.grid.clone();
}
if info.borders.is_some() && props.borders.is_none() {
props.borders = info.borders;
@@ -364,25 +318,10 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
props.indent = info.indent;
}
if info.cell_mark {
// A row-end (TTP) mark terminates the row. Word writes rows as N content
// cells each ended by their own cell mark, followed by an empty row-end mark
// that carries the row's TDefTable (the 3GPP change history: 8 content marks
// + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no
// content of its own and the TDefTable already has a column per flushed cell,
// it is a pure row terminator, not the end of a phantom (N+1)th cell — such a
// cell has no column (width None) and shifts every real cell's span one grid
// column over. A TTP mark with content, or one short of the TDefTable's
// columns, still ends a real cell (rows without a TDefTable keep today's
// shape: the count is unknowable, so the mark always flushes).
let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len();
// ...but a row-end mark with no cells before it still makes a (one-cell) row.
let row_end_only = row_end_only && !cells.is_empty();
if !p.para.text.is_empty() {
cell.push(para_block(p.para));
}
if !row_end_only {
flush_cell(&mut cells, &mut cell);
}
flush_cell(&mut cells, &mut cell);
if info.ttp {
let row = build_row(std::mem::take(&mut cells), &info);
rows.push(row);
@@ -397,23 +336,21 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
out
}
/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then
/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored:
/// per-row, in operand order; sorting and capping happen when the table grid is assembled.
fn def_table_edges(operand: &[u8]) -> Vec<f32> {
/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1
/// twip boundaries followed by the TC80s (skipped).
fn def_table_grid(operand: &[u8]) -> Vec<f32> {
let itc = operand.get(2).copied().unwrap_or(0) as usize;
if itc == 0 {
if itc == 0 || itc > MAX_COLS {
return Vec::new();
}
// itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end.
let mut edges = Vec::with_capacity((itc + 1).min(256));
let mut edges = Vec::with_capacity(itc + 1);
for i in 0..=itc {
match operand.get(3 + i * 2..3 + i * 2 + 2) {
Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0),
None => break,
}
}
edges
edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect()
}
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
@@ -444,8 +381,7 @@ fn brc80(b: &[u8]) -> Option<Border> {
}
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
/// show the foreground; other patterns are approximated by their background.
fn shd_rgb(b: &[u8]) -> Option<Rgb> {
let cv = |b: &[u8]| -> Option<Rgb> {
match b {
@@ -456,21 +392,19 @@ fn shd_rgb(b: &[u8]) -> Option<Rgb> {
};
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
match ipat {
0xFFFF => None,
0 | 0xFFFF => None,
1 => cv(b.get(0..4)?),
_ => cv(b.get(4..8)?),
}
}
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in
/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word
/// 97 writes a plain fill; ico 0 is automatic (no fill).
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields).
fn shd80_rgb(b: &[u8]) -> Option<Rgb> {
let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]);
let fore = v & 0x1F;
let back = (v >> 5) & 0x1F;
let ipat = v >> 10;
if fore == 0x1F && back == 0x1F {
if ipat == 0 || (fore == 0x1F && back == 0x1F) {
return None;
}
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
+2 -484
View File
@@ -8,8 +8,6 @@ use proptest::prelude::*;
use super::read;
mod drawing;
mod encrypted;
mod metadata;
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
const TEXT_FC: u32 = 0x600;
@@ -173,10 +171,10 @@ fn word_6_95_rejected() {
}
#[test]
fn encrypted_asks_for_a_password() {
fn encrypted_rejected() {
let spec = FibSpec { flags: 0x1100, ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]);
assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired)));
assert!(matches!(read(&f), Err(super::DocbinError::Encrypted)));
}
#[test]
@@ -1502,92 +1500,6 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() {
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
}
/// The text of each note a paragraph references, in order.
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
p.objects
.iter()
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
_ => None,
})
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn note_stories_pointing_at_the_same_text_are_read_once() {
// 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story
// ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole
// footnote subdocument each (1001 parts here; a large story times thousands of
// references in a hostile file).
let n = 2000u32;
let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str());
let ccp = text.chars().count() as u32;
let refs: Vec<u32> = (1..=n).chain([ccp]).collect();
let spec = StructSpec {
text,
note_stories: vec!["Note body\r"],
extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
}
#[test]
fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() {
// Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two
// stories are in order and read (the control); the third runs backwards and the fourth
// starts inside the first, so the third and fourth references get no note.
let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r";
let ccp = text.chars().count() as u32;
let stories = plc(&[0, 5, 10, 3, 10], &[]);
let spec = StructSpec {
text,
note_stories: vec!["Aaaa\rBbbb\r"],
endnote_stories: vec!["Cccc\rDddd\r"],
extra_pairs: vec![
(2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))),
(3, stories.clone()),
(46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))),
(47, stories),
],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from));
assert_eq!(doc.parts.len(), 4);
}
#[test]
fn header_stories_pointing_at_the_same_text_are_read_once() {
// Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd
// steps back to CP 0 inside the second section's slots): the second used to become a
// second copy of the text. Its slot is now empty, which links it to the first section's.
let text = "a\rb\r";
let ccp = text.chars().count() as u32;
// Six empty separator stories, then each section's six slots; the odd header is the
// second slot.
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]);
let spec = StructSpec {
text,
header_stories: vec!["Head\r"],
sections: vec![(2, Vec::new()), (ccp, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
extra_pairs: vec![(11, hdd)],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
assert_eq!(doc.last_section.headers.default, None);
}
/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes
/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters.
fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec<u8> {
@@ -1725,31 +1637,6 @@ fn picture_bytes_are_capped_per_document() {
const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) };
}
#[test]
fn rejected_inline_pictures_are_not_read_again() {
// A picture over the cap was located and copied again for every anchor on it.
let big = picf_with_png(1200);
let small = picf_with_png(300);
let data: Vec<u8> = [big.clone(), small.clone()].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
assert!(pics.get(&data, 0).is_none(), "over the cap");
// The failure is remembered: that place isn't read again, whatever it holds now.
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
let rest_at = data.len() as u32;
let tail: Vec<u8> = [data, rest.clone(), small].concat();
assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap");
assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up");
assert_eq!(pics.media.len(), 2);
}
#[test]
fn row_height_of_i16_min_does_not_overflow() {
// sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic).
@@ -1916,372 +1803,3 @@ fn alternate_font_name_keeps_later_entries() {
assert_eq!(fonts, [Some("Arial".to_string()), Some("Courier New".to_string())], "alt {alt:?}");
}
}
/// PChgTabsPapxOperand bytes: cb, removed stops, added stops with their TBDs.
fn tabs_papx(del: &[i16], add: &[(i16, u8)]) -> Vec<u8> {
let mut v = vec![0u8, del.len() as u8];
for d in del {
v.extend_from_slice(&d.to_le_bytes());
}
v.push(add.len() as u8);
for (x, _) in add {
v.extend_from_slice(&x.to_le_bytes());
}
v.extend(add.iter().map(|(_, tbd)| *tbd));
v[0] = (v.len() - 1) as u8;
v
}
/// Custom tab stops ([MS-DOC] sprmPChgTabsPapx, sprmPChgTabs) were skipped, so tab-aligned text
/// (centred titles, right-aligned dates, dot leaders in forms) lost its alignment. Stops added by
/// a style and by the paragraph, stops it removes, and the TBD's alignment and leader are read.
#[test]
fn tab_stops_read_from_styles_and_paragraphs() {
use wordcraft_doc::props::{TabAlign, TabLeader};
// Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1).
let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]);
// The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1".
let direct = grpprl(&[(0xC60D, &tabs_papx(&[4680], &[(720, 0), (1440, 4)]))]);
// The second paragraph uses the older sprmPChgTabs: removals carry a "close" distance.
let mut old = vec![0u8, 1];
old.extend_from_slice(&9360i16.to_le_bytes());
old.extend_from_slice(&25i16.to_le_bytes());
old.push(1);
old.extend_from_slice(&2880i16.to_le_bytes());
old.push(3 | 5 << 3); // decimal, middle dot
old[0] = (old.len() - 1) as u8;
let spec = RichSpec {
text: "\tA\tB\r\tC\r",
styles: vec![(1, 0x0FFF, "Normal", normal, Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, direct), (5, 0, grpprl(&[(0xC615, &old)]))],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let tabs = |i: usize| {
let p = doc.body.get(i).and_then(|b| b.as_para()).expect("para");
doc.styles.resolve_para(&p.props).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
};
assert_eq!(
tabs(0),
[(36.0, TabAlign::Left, TabLeader::None), (72.0, TabAlign::Bar, TabLeader::None), (468.0, TabAlign::Right, TabLeader::Dot)],
"the style's stops, less the removed one, plus the paragraph's"
);
assert_eq!(tabs(1), [(144.0, TabAlign::Decimal, TabLeader::MiddleDot), (234.0, TabAlign::Center, TabLeader::None)], "sprmPChgTabs");
assert_eq!(doc.styles.styles.iter().find(|s| s.name == "Normal").and_then(|s| s.para.tabs.as_ref()).map(Vec::len), Some(2));
}
/// Hostile tab operands (counts past the end, more than 64 stops, cb 255) never panic and
/// never swallow the sprms after them.
#[test]
fn hostile_tab_operands_are_safe() {
for operand in [vec![2u8, 200, 9], vec![0], vec![255, 1, 0x10, 0x00], vec![255, 3], vec![5, 0, 70, 0, 1, 2]] {
let papx = grpprl(&[(0xC615, &operand), (0x2403, &[1])]);
let spec = RichSpec {
text: "x\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Calibri"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, papx)],
};
let _ = read(&rich_doc(&spec));
}
let mut iter = crate::sprm::iter(&[0x15, 0xC6, 3, 0, 0, 0, 0x03, 0x24, 1]);
assert_eq!(iter.next().map(|p| (p.op, p.operand.len())), Some((0xC615, 4)), "cb is one byte: the operand is cb + 1 bytes");
assert_eq!(iter.next().map(|p| p.op), Some(0x2403), "the next sprm is still read");
}
/// A section mark (0x0C) at a section's end is the section's last paragraph mark, not a page
/// break inside a paragraph: the section's properties (here continuous) land on that paragraph.
/// Before, every section of such a file was lost and each section mark became a page break.
#[test]
fn section_marks_end_paragraphs() {
use wordcraft_doc::section::SectionStart;
let spec = StructSpec {
text: "first\u{C}second\r",
header_stories: vec![],
sections: vec![(6, grpprl(&[(0x3009, &[0x00])])), (13, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["first", "second"]);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(p0.section.as_ref().map(|s| s.start), Some(SectionStart::Continuous), "the section ends at its mark");
// A page break inside a section stays a page break.
let spec = StructSpec { text: "a\u{C}b\r", sections: vec![(4, Vec::new())], papx_entries: vec![(0, 0, Vec::new())], ..spec };
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
}
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
/// after it, plus one; Word may store fewer TC80s than cells).
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
for e in edges {
body.extend_from_slice(&e.to_le_bytes());
}
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
v.extend_from_slice(&body);
v
}
/// A variable-length table sprm whose operand is cb then `body`.
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
let mut v = op.to_le_bytes().to_vec();
v.push(body.len() as u8);
v.extend_from_slice(body);
v
}
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
/// Word writes a plain cell fill.
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
one.repeat(n)
}
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()), // a..e cell marks
(10, 0, ttp(row1)), // row 1 end mark
(11, 0, in_tbl.clone()),
(21, 0, ttp(row2)),
(22, 0, in_tbl),
(29, 0, ttp(row3)),
(30, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
read(&struct_doc(&spec)).expect("opens")
}
#[test]
fn word_table_row_end_mark_and_cell_shading() {
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
// 365 writes it. The exact colours must beat the palette colour.
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
let row1 = [
t_def_edges(&edges5),
sprm_cb(0xD609, &shd80),
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
]
.concat();
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
// Row 3: one cell from the first edge to the last, filled FFF2CC.
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
let doc = word_table_doc(row1, row2, row3);
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.grid.len(), 5, "grid columns");
assert_eq!(tbl.rows.len(), 3);
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(legend, "legend");
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
assert_eq!(tail.text, "tail");
}
#[test]
fn word97_shd80_clear_fill_shows_its_background() {
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
// solid (ipat 1) with icoFore 2 (blue).
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
let papx = sprm_cb(0xD609, &shd80);
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(2), None);
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
}
#[test]
fn shd_arrays_keep_to_their_cell_ranges() {
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
let red = shd_clear([0xFF, 0, 0], 1);
let green = shd_clear([0, 0xFF, 0], 1);
let blue = shd_clear([0, 0, 0xFF], 1);
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
let papx = sprm_cb(first, &red);
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
}
#[test]
fn hostile_table_shading_and_spans_are_safe() {
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
// run backwards or past the grid; merges past the row's end.
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
let long = shd_clear([1, 2, 3], 6);
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
(
{
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&[0xFF, 0x00, 200]);
v.extend_from_slice(&[0x10; 24]);
v
},
t_def_edges(&[7200, 0, -5, 30000]),
t_def_edges(&[-32000, 32000]),
),
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
];
for (r1, r2, r3) in rows {
let doc = word_table_doc(r1, r2, r3);
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
assert!(t.grid.len() <= 63);
for r in &t.rows {
assert!(r.cells.iter().all(|c| c.props.span <= 63));
}
}
}
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
// merge and a SHD array, then a normal row.
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
// must not turn the lone row-end mark into a row with no cells.
let text = "\u{7}a\u{7}\u{7}tail\r";
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let one_edge = {
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
v.extend_from_slice(&[3, 0, 0]);
v
};
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
let spec = one_section(
text,
Vec::new(),
vec![
(0, 0, ttp(lone)),
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
(3, 0, ttp(t_def_edges(&[0, 1440]))),
(4, 0, Vec::new()),
],
);
let doc = read(&struct_doc(&spec)).expect("opens");
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
for t in doc.body.iter().filter_map(|b| b.as_table()) {
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
}
}
}
#[test]
fn nil_raw_shading_keeps_the_earlier_colour() {
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
let blue = shd_clear([0, 0, 0xFF], 1);
let red = shd_clear([0xFF, 0, 0], 1);
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(2), None, "automatic clears");
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
let cell = if first == 0xD616 { 22 } else { 44 };
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
}
}
#[test]
fn second_table_gets_its_own_grid() {
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
// comes from its own rows, not the first table's, and its borders, alignment and indent
// are its own too (the first table's centred, bordered and indented).
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
let table1_props = {
let mut borders = (0xD605u16).to_le_bytes().to_vec();
borders.push(0x18);
for _ in 0..6 {
borders.extend_from_slice(&[4, 1, 1, 0]);
}
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()),
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
(11, 0, Vec::new()), // "between"
(19, 0, in_tbl),
(25, 0, ttp(&[0, 2000, 4000, 7200])),
(26, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(tables.len(), 2);
assert_eq!(tables[0].grid.len(), 5);
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
assert_eq!(tables[1].props.borders, None, "borders reset");
assert_eq!(tables[1].props.align, None, "alignment reset");
assert_eq!(tables[1].props.indent, None, "indent reset");
}
-57
View File
@@ -182,36 +182,6 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
// in between): each was read into its own part, so a hostile table could copy one story
// thousands of times. The second story is dropped and its box stays empty.
let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
let art = art_content(&[], &[tb(1027), tb(1028)]);
let flags = spa_flags(0, 0, 1, false);
let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat());
// FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo.
let ftxbxs = |lid: u32| {
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()]
.concat()
};
let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat());
let text = "A\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
// As above: the endnote slot holds the text box subdocument, then the FIB is patched.
let spec =
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let stories: Vec<Option<u32>> =
p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect();
let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") };
let part = doc.parts.get(&first).expect("text box story part");
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
assert_eq!(doc.parts.len(), 1);
}
/// Overwrite little-endian u32s in the WordDocument stream of a compound file.
fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec<u8> {
use std::io::Read;
@@ -304,30 +274,3 @@ proptest! {
let _ = read(&struct_doc(&spec));
}
}
// A 1×1 24-bit DIB, including its padded four-byte row.
fn one_pixel_dib() -> Vec<u8> {
let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
dib.extend_from_slice(&1u16.to_le_bytes());
dib.extend_from_slice(&24u16.to_le_bytes());
dib.extend_from_slice(&[0u8; 24]);
dib.extend_from_slice(&[30, 60, 90, 0]);
dib
}
#[test]
fn dib_conversion_caps_header_and_encoded_bytes() {
let dib = one_pixel_dib();
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (record, _) = officeart::record(&record).unwrap();
let bmp_size = 14 + dib.len();
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
assert_eq!((normal.ext, normal.placeholder), ("png", false));
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
}
-238
View File
@@ -1,238 +0,0 @@
//! Password-protected files. The fixtures are encrypted here with a small encryptor written from
//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used.
use std::io::Read;
use sha1::{Digest, Sha1};
use super::{Piece, body_texts, cfb_file, text_doc};
use crate::DocbinError;
use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier};
use crate::{is_encrypted, read, read_with_password};
const TEXT: &str = "Secret \u{20ac} plans";
/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33.
const CLX_PAIR: usize = 0x9A + 33 * 8;
fn plain() -> (Vec<u8>, Vec<u8>) {
let f = text_doc(&[Piece { compressed: false, text: format!("{TEXT}\r") }], &[], 0);
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(f)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
}
fn rewrap(word: &[u8], table: &[u8]) -> Vec<u8> {
cfb_file(&[("WordDocument", word), ("0Table", table)])
}
fn put16(v: &mut [u8], at: usize, x: u16) {
v[at..at + 2].copy_from_slice(&x.to_le_bytes());
}
fn put32(v: &mut [u8], at: usize, x: u32) {
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
}
fn utf16(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// Encrypt (= RC4 is symmetric) `data` in 512-byte blocks; the first `skip` bytes stay as they are
/// but the keystream still runs over them.
fn rc4_stream(data: &mut [u8], skip: usize, key: &dyn Fn(u32) -> Vec<u8>) {
for (n, chunk) in data.chunks_mut(512).enumerate() {
let orig = chunk.to_vec();
Rc4::new(&key(n as u32)).apply(chunk);
let keep = skip.saturating_sub(n * 512).min(chunk.len());
chunk[..keep].copy_from_slice(&orig[..keep]);
}
}
/// Put `header` in front of the table and set the FIB's encryption fields.
fn assemble(mut word: Vec<u8>, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec<u8>, Vec<u8>) {
let fl = u16::from_le_bytes([word[0x0A], word[0x0B]]) | flags;
put16(&mut word, 0x0A, fl);
put32(&mut word, 0x0E, lkey);
let fc = u32::from_le_bytes(word[CLX_PAIR..CLX_PAIR + 4].try_into().unwrap());
put32(&mut word, CLX_PAIR, fc + header.len() as u32);
([word].concat(), [header, table].concat())
}
fn xor_file(pw: &[u8]) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &[], F_ENCRYPTED | F_OBFUSCATED, xor_verifier(pw));
let arr = xor_array(pw);
for (data, skip) in [(&mut word, 68), (&mut table, 0)] {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
if *b != 0 && *b ^ arr[i % 16] != 0 {
*b ^= arr[i % 16];
}
}
}
rewrap(&word, &table)
}
fn rc4_header(pw: &str) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [7u8; 16];
let h0 = md5(&utf16(pw));
let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::<Vec<u8>>());
let trunc = h1[..5].to_vec();
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| md5(&[trunc.as_slice(), &b.to_le_bytes()].concat()).to_vec());
let verifier = *b"0123456789abcdef";
let mut enc = [verifier.to_vec(), md5(&verifier).to_vec()].concat();
Rc4::new(&key(0)).apply(&mut enc);
([&[1, 0, 1, 0][..], &salt, &enc].concat(), key)
}
fn capi_header(pw: &str, bits: u32, hflags: u32) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [9u8; 16];
let h0 = sha1(&[&salt, &utf16(pw)]);
let n = (bits / 8) as usize;
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| {
let mut k = sha1(&[&h0, &b.to_le_bytes()])[..n].to_vec();
if n == 5 {
k.resize(16, 0);
}
k
});
let verifier = *b"fedcba9876543210";
let mut enc = [verifier.to_vec(), sha1(&[&verifier])].concat();
Rc4::new(&key(0)).apply(&mut enc);
let mut body = Vec::new();
for x in [hflags, 0, 0x6801, 0x8004, bits, 1, 0, 0] {
body.extend_from_slice(&x.to_le_bytes());
}
body.extend_from_slice(&utf16("Test RC4 provider\0"));
let mut h = vec![3, 0, 2, 0];
h.extend_from_slice(&hflags.to_le_bytes());
h.extend_from_slice(&(body.len() as u32).to_le_bytes());
h.extend_from_slice(&body);
h.extend_from_slice(&16u32.to_le_bytes());
h.extend_from_slice(&salt);
h.extend_from_slice(&enc[..16]);
h.extend_from_slice(&20u32.to_le_bytes());
h.extend_from_slice(&enc[16..]);
(h, key)
}
fn rc4_file_with(header: Vec<u8>, key: &dyn Fn(u32) -> Vec<u8>) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &header, F_ENCRYPTED, header.len() as u32);
rc4_stream(&mut word, 68, key);
rc4_stream(&mut table, header.len(), key);
rewrap(&word, &table)
}
fn rc4_file(pw: &str) -> Vec<u8> {
let (h, k) = rc4_header(pw);
rc4_file_with(h, &*k)
}
fn capi_file(pw: &str, bits: u32) -> Vec<u8> {
let (h, k) = capi_header(pw, bits, 0x04);
rc4_file_with(h, &*k)
}
fn check(f: &[u8], pw: &str, wrong: &str) {
assert!(is_encrypted(f));
assert_eq!(read(f).unwrap_err(), DocbinError::PasswordRequired);
assert_eq!(read_with_password(f, Some(wrong)).unwrap_err(), DocbinError::WrongPassword);
assert_eq!(read_with_password(f, Some("")).unwrap_err(), DocbinError::WrongPassword);
let doc = read_with_password(f, Some(pw)).unwrap();
assert_eq!(body_texts(&doc), [TEXT]);
}
#[test]
fn xor_obfuscation_round_trip() {
check(&xor_file(b"hunter2"), "hunter2", "hunter3");
// Longer than 15 characters: only the first 15 count, as in the format.
let long = xor_file(b"abcdefghijklmno");
assert_eq!(body_texts(&read_with_password(&long, Some("abcdefghijklmnopqrst")).unwrap()), [TEXT]);
// A non-ASCII (Latin-1) password.
check(&xor_file(&[b'p', 0xE4, b's', b's']), "p\u{e4}ss", "pass");
}
#[test]
fn rc4_round_trip() {
check(&rc4_file("correct horse"), "correct horse", "Correct horse");
check(&rc4_file("p\u{e4}ssw\u{f6}rd \u{1f511}"), "p\u{e4}ssw\u{f6}rd \u{1f511}", "password");
}
#[test]
fn rc4_crypto_api_round_trip() {
check(&capi_file("battery staple", 128), "battery staple", "battery stapl");
// 40-bit keys are zero-padded to 128 bits.
check(&capi_file("battery staple", 40), "battery staple", "x");
}
#[test]
fn unencrypted_files_ignore_the_password() {
let f = text_doc(&[Piece { compressed: false, text: "Plain\r".into() }], &[], 0);
assert!(!is_encrypted(&f));
assert_eq!(body_texts(&read_with_password(&f, Some("whatever")).unwrap()), ["Plain"]);
}
#[test]
fn a_tampered_verifier_is_a_wrong_password_never_a_garbled_document() {
let (h, k) = rc4_header("pw");
let mut bad = h.clone();
bad[40] ^= 1; // inside the encrypted verifier hash
assert_eq!(read_with_password(&rc4_file_with(bad, &*k), Some("pw")).unwrap_err(), DocbinError::WrongPassword);
}
#[test]
fn unsupported_variants_say_so() {
// AES and external providers, an unknown header version.
for hflags in [0x04 | 0x20, 0x10] {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
assert!(matches!(read_with_password(&rc4_file_with(h, &*k), Some("pw")), Err(DocbinError::Encryption(_))));
}
#[test]
fn damaged_headers_are_errors_never_panics() {
for f in [rc4_header("pw"), capi_header("pw", 128, 0x04)] {
let (h, k) = f;
let full = rc4_file_with(h.clone(), &*k);
let (word, table) = {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(&full)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
};
// Truncated everywhere in and around the header: an error, not a panic.
for n in 0..h.len() + 8 {
let r = read_with_password(&rewrap(&word, &table[..n.min(table.len())]), Some("pw"));
assert!(r.is_err(), "length {n}");
}
// Every header byte garbled.
for at in 0..h.len() {
let mut t = table.clone();
t[at] ^= 0xFF;
let _ = read_with_password(&rewrap(&word, &t), Some("pw"));
}
}
// A header size claiming more than the stream holds.
let (mut h, k) = capi_header("pw", 128, 0x04);
h[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(read_with_password(&rc4_file_with(h, &*k), Some("pw")).is_err());
}
-98
View File
@@ -1,98 +0,0 @@
//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader.
use super::{Piece, body_texts, text_doc};
use std::io::{Cursor, Seek, SeekFrom, Write};
fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())];
for (id, kind, bytes) in values {
let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32;
let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat();
while !value.len().is_multiple_of(4) {
value.push(0);
}
props.push((*id, value));
}
let mut section = vec![0; 8 + 8 * props.len()];
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
for (i, (id, value)) in props.iter().enumerate() {
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
let offset = section.len() as u32;
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
section.extend_from_slice(value);
}
let size = section.len() as u32;
section[..4].copy_from_slice(&size.to_le_bytes());
let mut header = vec![0; 48];
header[..2].copy_from_slice(&[0xfe, 0xff]);
header[24..28].copy_from_slice(&1u32.to_le_bytes());
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
header[44..48].copy_from_slice(&48u32.to_le_bytes());
header.extend(section);
header
}
fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document {
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();
if template {
let mut stream = cfb.open_stream("/WordDocument").unwrap();
stream.seek(SeekFrom::Start(0x0a)).unwrap();
stream.write_all(&0x1001u16.to_le_bytes()).unwrap();
}
let doc = crate::read(&cfb.into_inner().into_inner()).unwrap();
assert_eq!(body_texts(&doc), ["Body stays readable"]);
doc
}
#[test]
fn doc_and_dot_import_summary_text_properties() {
let data = summary(
1252,
&[
(2, 30, b"R\xe9sum\xe9\0".to_vec()),
(3, 30, b"Subject\0".to_vec()),
(4, 30, b"Writer\0".to_vec()),
(5, 30, b"one;two\0".to_vec()),
(6, 30, b"Description\0".to_vec()),
(8, 30, b"Editor\0".to_vec()),
(9, 30, b"12\0".to_vec()),
],
);
for template in [false, true] {
let c = open_with_metadata(&data, template).core;
assert_eq!(c.title, "Résumé");
assert_eq!(c.subject, "Subject");
assert_eq!(c.creator, "Writer");
assert_eq!(c.keywords, "one;two");
assert_eq!(c.description, "Description");
assert_eq!(c.last_modified_by, "Editor");
assert_eq!(c.revision, 12);
}
}
#[test]
fn summary_handles_codepages_and_utf16_length_units() {
let wide: Vec<u8> = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect();
for kind in [30, 31] {
assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀");
}
assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä");
assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет");
}
#[test]
fn malformed_or_oversized_summary_does_not_break_doc_open() {
let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]);
for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] {
assert!(open_with_metadata(&valid[..n], false).core.title.is_empty());
}
for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] {
let mut bad = valid.clone();
bad[at..at + 4].copy_from_slice(&value.to_le_bytes());
assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}");
}
let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]);
assert!(open_with_metadata(&huge, false).core.title.is_empty());
assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty());
}
+18 -101
View File
@@ -5,9 +5,7 @@
//!
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
//! every part counts against the part count, part size and total size limits whether it's kept
//! 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.
//! part count, part size, total size and base64 length are capped.
use std::collections::BTreeMap;
@@ -94,24 +92,8 @@ struct Part {
data: Option<Data>,
}
/// Deepest element nesting read: a part's inline XML starts below the package, part and data
/// elements and may nest as deep as the XML tree reader takes ([`xml::MAX_DEPTH`] open elements
/// and an empty one inside the deepest).
const MAX_NESTING: usize = 3 + xml::MAX_DEPTH + 1;
/// Most elements in one child of the package: a part, its data element and as many as the XML
/// tree reader takes in a part ([`xml::MAX_ELEMENTS`]).
const MAX_PART_ELEMENTS: usize = 2 + xml::MAX_ELEMENTS;
/// Longest base64 text of a part that decodes to at most `bytes` (whitespace isn't kept).
fn max_base64(bytes: u64) -> u64 {
(bytes / 3).saturating_add(1).saturating_mul(4)
}
/// Bytes the next part may have: its own size limit, or what's left of the package's.
fn room(total: u64) -> u64 {
MAX_PART.min(MAX_TOTAL.saturating_sub(total))
}
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
/// content types (unless the file carries one).
@@ -121,9 +103,6 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
let mut r = NsReader::from_reader(src);
r.config_mut().check_end_names = true;
let mut depth = 0usize;
// Children of the package (parts or anything else), and elements in the current one.
let mut entries = 0usize;
let mut elements = 0usize;
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut types: Vec<(String, String)> = Vec::new();
let mut total: u64 = 0;
@@ -139,26 +118,15 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
Event::Start(ref e) | Event::Empty(ref e) => {
let empty = matches!(ev, Event::Empty(_));
depth += 1;
if depth > MAX_NESTING {
return Err(DocxError::Limit("XML nesting too deep".into()));
}
if depth == 2 {
entries += 1;
elements = 0;
if entries > MAX_ENTRIES {
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
}
}
elements += 1;
if elements > MAX_PART_ELEMENTS {
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = e.local_name().as_ref().to_vec();
match depth {
1 if !(in_pkg && local == b"package") => {
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
}
2 if in_pkg && local == b"part" => {
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
}
let name = attr(e, b"name").unwrap_or_default();
let content_type = attr(e, b"contentType").unwrap_or_default();
part = Some(Part { name, content_type, data: None });
@@ -199,12 +167,7 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
&& let Some(Data::Xml(start)) = p.data
{
p.data = None;
let frag = src.get(start..before).unwrap_or_default();
// Refused before it's copied when it can't fit.
if frag.len() as u64 > room(total) {
return Err(DocxError::Limit(format!("part {} is larger than the size limit", p.name)));
}
let xml = with_scope(frag, &scope);
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
}
if depth == 2
@@ -220,18 +183,15 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
break;
}
}
Event::Text(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
Event::CData(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
Event::GeneralRef(ref g) if depth == 3 => match g.resolve_char_ref() {
// A character reference stands for its character (`&#65;` is an `A`).
Ok(Some(c)) => add_base64(&mut part, c.encode_utf8(&mut [0; 4]).as_bytes(), total)?,
// An entity isn't base64: binary data holding one is broken, not shorter.
_ => {
if let Some(Part { name, data: Some(Data::Binary(_)), .. }) = &part {
return Err(DocxError::Xml(format!("part {name} has invalid base64 data")));
Event::Text(ref t) if depth == 3 => {
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
if b64.len() as u64 > MAX_BASE64 {
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
}
}
},
}
Event::Eof => break,
_ => {}
}
@@ -265,21 +225,7 @@ fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(
}
}
/// Text (or CDATA) inside a part's `pkg:binaryData`: its base64 without whitespace, checked
/// against the size limits before it's copied.
fn add_base64(part: &mut Option<Part>, text: &[u8], total: u64) -> Result<(), DocxError> {
let Some(Part { data: Some(Data::Binary(b64)), .. }) = part else { return Ok(()) };
let s = std::str::from_utf8(text).map_err(|e| DocxError::Xml(e.to_string()))?;
let more = s.bytes().filter(|b| !b.is_ascii_whitespace()).count();
if b64.len().saturating_add(more) as u64 > max_base64(room(total)) {
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
}
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
Ok(())
}
/// Add a part, within the size limits; a repeated or empty name isn't stored but counts against
/// them all the same (as a repeated zip entry does).
/// Add a part, within the size limits; a repeated or empty name is ignored.
fn store(
name: &str,
content_type: &str,
@@ -289,6 +235,9 @@ fn store(
total: &mut u64,
) -> Result<(), DocxError> {
let name = name.trim_start_matches('/');
if name.is_empty() || files.contains_key(name) {
return Ok(());
}
let len = bytes.len() as u64;
if len > MAX_PART {
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
@@ -297,9 +246,6 @@ fn store(
if *total > MAX_TOTAL {
return Err(DocxError::Limit("the package is larger than the size limit".into()));
}
if name.is_empty() || files.contains_key(name) {
return Ok(());
}
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
types.push((name.to_string(), content_type.to_string()));
}
@@ -462,33 +408,4 @@ mod tests {
assert_eq!(with_scope(own, &scope), own.to_vec());
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
#[test]
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
let package = |levels: usize| {
format!(
"<pkg:package xmlns:pkg=\"{PKG_NS}\"><pkg:part pkg:name=\"/a.xml\"><pkg:xmlData>{}</pkg:xmlData></pkg:part></pkg:package>",
inline(levels)
)
};
assert!(xml::parse(inline(xml::MAX_DEPTH).as_bytes()).is_ok());
assert!(unpack(package(xml::MAX_DEPTH).as_bytes()).is_ok());
assert!(matches!(xml::parse(inline(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
}
#[test]
fn repeated_and_nameless_parts_count_against_the_size_limits() {
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
for name in ["/a.bin", "/a.bin", ""] {
store(name, "", vec![0; 10], &mut files, &mut types, &mut total).unwrap();
}
assert_eq!((files.len(), total), (1, 30));
// Near the package limit, what's left of it is all the next part may have.
total = MAX_TOTAL - 5;
assert_eq!(room(total), 5);
assert_eq!(max_base64(5), 8);
assert!(matches!(store("/b.bin", "", vec![0; 6], &mut files, &mut types, &mut total), Err(DocxError::Limit(_))));
}
}
-72
View File
@@ -1,72 +0,0 @@
//! Fonts a file embeds through its font table (#1171).
//!
//! The reader keeps the font table and its font parts as opaque passthrough parts, for writing
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
use wordcraft_doc::Document;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
/// Most font files taken from one document.
const MAX_FONTS: usize = 64;
/// The font table's references to embedded font files, one per style.
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be
/// fonts: that is up to the font database. Empty for a document read from anything but a package
/// with a font table.
pub fn embedded_fonts(doc: &Document) -> Vec<Vec<u8>> {
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
return Vec::new();
};
let mut out = Vec::new();
for embed in table.children("w:font").flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str()))) {
if out.len() >= MAX_FONTS {
log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded");
break;
}
let Some(rel) = embed.attr("r:id").and_then(|id| part.rels.iter().find(|r| r.id == id && !r.external)) else {
continue;
};
let Some(bytes) = doc.passthrough.get(&rel.target) else {
continue;
};
// No key: the file is stored as is, as with the all-zero key.
let key = match embed.attr("w:fontKey") {
Some(k) => match font_key(k) {
Some(key) => key,
None => {
log::warn!("docx: embedded font {} has an unreadable key {k:?}", rel.target);
continue;
}
},
None => [0; 16],
};
let mut data = bytes.to_vec();
for (i, b) in data.iter_mut().take(32).enumerate() {
*b ^= key.get(i % 16).copied().unwrap_or(0);
}
out.push(data);
}
out
}
/// The obfuscation key of a `w:fontKey` GUID (`{XXXXXXXX-XXXX-…}`): its 16 bytes, reversed.
fn font_key(guid: &str) -> Option<[u8; 16]> {
let digits: Vec<u8> = guid.bytes().filter(|c| !matches!(c, b'{' | b'}' | b'-')).collect();
if digits.len() != 32 {
return None;
}
let mut key = [0u8; 16];
for (i, pair) in digits.chunks(2).enumerate() {
let byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
*key.get_mut(15 - i)? = byte;
}
Some(key)
}
-4
View File
@@ -11,13 +11,11 @@ mod chart_spec;
mod crypt;
mod custom;
mod flat;
mod fonts;
mod package;
mod read;
mod smart_art;
mod units;
mod wordml2003;
mod wordml2003_write;
mod write;
mod xml;
@@ -27,10 +25,8 @@ pub use crypt::{
is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::embedded_fonts;
pub use read::read;
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
pub use write::{write, write_as};
/// Largest `.xml` file read as a Word XML document.
-20
View File
@@ -24,14 +24,11 @@ pub mod rt {
pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate";
pub const FONT_TABLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
pub const COMMENTS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
pub const COMMENTS_EX: &str = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
pub const COMMENTS_IDS: &str = "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
pub const COMMENTS_EXTENSIBLE: &str = "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible";
pub const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
pub const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
@@ -60,26 +57,9 @@ pub const MAX_VBA_RELATED: usize = 64;
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
/// part name. Not a package part: WordCraft's own bookkeeping.
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
/// Extended properties (`docProps/app.xml`) the model doesn't hold but a save keeps
/// ([`APP_KEPT_PROPS`]): `name\tvalue` lines. Not a package part: WordCraft's own bookkeeping.
pub const APP_KEPT: &str = "wordcraft:app.kept";
/// The `docProps/app.xml` properties carried over from the file. Counts (pages, lines,
/// characters, editing time) and the producing application's version are not: they describe
/// the file Word saved, not the one WordCraft writes.
pub const APP_KEPT_PROPS: &[&str] =
&["Template", "Manager", "Company", "HyperlinkBase", "ScaleCrop", "LinksUpToDate", "SharedDoc", "HyperlinksChanged"];
/// Parts WordCraft doesn't model, carried verbatim: `owner\ttype\tpart` lines (owner `P` for
/// the package, `D` for the main document), with their bytes and relationships in the
/// [`EmbeddedManifest`]. Not a package part: WordCraft's own bookkeeping.
pub const UNMODELLED_ROOTS: &str = "wordcraft:unmodelled.roots";
/// Most parts we carry along for a file's embedded objects.
pub const MAX_EMBEDDED_PARTS: usize = 4096;
/// Most relationships we keep for one such part.
+10 -76
View File
@@ -2,8 +2,7 @@
//!
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
//! charts are drawn from the first supported chart type of the plot area, with their values as
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
use std::f64::consts::{FRAC_PI_2, TAU};
@@ -46,21 +45,20 @@ const TEXT: Rgb = Rgb(0x59, 0x59, 0x59);
const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9);
const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF);
/// Items for a chart part, sized `w` x `h` points: a placeholder when only unsupported chart
/// types are in it, empty when there's nothing to draw.
/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it.
pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
let mut out = Vec::new();
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return out;
}
let Some(chart) = Chart::parse(space, theme) else {
placeholder(&mut out, space, theme, w, h);
return out;
};
let Some(chart) = Chart::parse(space, theme) else { return out };
backdrop(&mut out, space, theme, w, h);
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
if let Some(t) = &chart.title {
area = title_block(&mut out, t, area);
let lh = TITLE_SIZE * LINE;
text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
area.y += lh + PAD;
area.h = (area.h - lh - PAD).max(1.0);
}
if let Some(pos) = chart.legend {
// Word lists bar series in the order they are drawn: reversed for clustered horizontal bars
@@ -80,53 +78,6 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
out
}
/// The title at the top of `area`; returns the room left under it.
fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
let lh = TITLE_SIZE * LINE;
text(out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
}
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
/// to draw.
fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
let Some(chart) = space.child("c:chart") else { return };
let Some(plot) = chart.child("c:plotArea") else { return };
if plot.els().any(|e| Kind::of(&e.name).is_some()) {
return;
}
let Some(ty) = plot.els().find(|e| e.name.ends_with("Chart")) else { return };
backdrop(out, space, theme, w, h);
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill: None, stroke: Some(AXIS), stroke_width: 0.75 });
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
if let Some(t) = title_of(chart, &read_series(ty, false, theme)) {
area = title_block(out, &t, area);
}
let size = text_size(space);
let lh = size * LINE;
let msg = format!("{} (not supported yet)", unsupported_name(&ty.name));
let line = Rect { x: area.x, y: area.y + ((area.h - lh) / 2.0).max(0.0), w: area.w, h: lh };
text(out, line, &clip_text(&msg, area.w, size), size, TextAlign::Center, false);
}
/// What a placeholder calls a chart type element WordCraft doesn't draw.
fn unsupported_name(name: &str) -> &'static str {
match name {
"c:radarChart" => "Radar chart",
"c:stockChart" => "Stock chart",
"c:bubbleChart" => "Bubble chart",
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
"c:ofPieChart" => "Pie of pie chart",
"c:bar3DChart" => "3-D bar chart",
"c:line3DChart" => "3-D line chart",
"c:area3DChart" => "3-D area chart",
"c:pie3DChart" => "3-D pie chart",
_ => "Chart",
}
}
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
struct DataLabel {
@@ -1841,27 +1792,10 @@ mod tests {
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
}
fn texts(items: &[GraphicItem]) -> Vec<&str> {
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect()
}
#[test]
fn unsupported_chart_types_draw_a_placeholder() {
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
assert!(framed, "a light border marks the chart's frame: {radar:?}");
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
fn unsupported_chart_types_draw_nothing() {
let plot = r#"<c:radarChart><c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:radarChart>"#;
assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty());
}
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
+2 -77
View File
@@ -6,8 +6,7 @@
//! reaches through relationships (chart, embedded workbook, colours and style; diagram data, layout,
//! quick style, colours and drawing; OLE embedding and its picture…) as opaque bytes in
//! `Document::passthrough`, with their content types and relationships in an
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
//! are kept the same way.
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest).
use std::sync::Arc;
@@ -19,48 +18,6 @@ use crate::package::{
};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
/// 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.
/// Types the main document refers to by id (headers, pictures, objects…) are listed too.
pub const HANDLED_RELS: &[&str] = &[
"officeDocument",
"core-properties",
"extended-properties",
"custom-properties",
"styles",
"numbering",
"settings",
"theme",
"fontTable",
"footnotes",
"endnotes",
"comments",
"commentsExtended",
"commentsIds",
"commentsExtensible",
"customXml",
"vbaProject",
"header",
"footer",
"image",
"hyperlink",
"oleObject",
"package",
"chart",
"chartEx",
"diagramData",
"diagramDrawing",
"diagramLayout",
"diagramQuickStyle",
"diagramColors",
"video",
"audio",
"media",
"control",
"subDocument",
"ink",
];
/// Most relationship ids one object's markup may use.
const MAX_IDS: usize = 64;
@@ -87,38 +44,6 @@ impl Reader<'_> {
}
}
/// Keep the parts the package and the main document relate to that WordCraft neither models
/// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a
/// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the
/// 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 main = self.pkg.get(&self.main).map(|b| b.to_vec()).unwrap_or_default();
let referenced = |id: &str| {
let needle = format!("\"{id}\"");
main.windows(needle.len()).any(|w| w == needle.as_bytes())
};
let mut roots = String::new();
for (owner, rels) in [("P", package), ("D", document)] {
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))
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
{
continue;
}
if self.keep_part_tree(&rel.target) {
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
}
}
}
if !roots.is_empty() {
self.doc.passthrough.insert(crate::package::UNMODELLED_ROOTS.into(), Arc::new(roots.into_bytes()));
}
}
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
/// to, so the object can be written back. `extra` names relationships the object's parts use
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
@@ -148,7 +73,7 @@ impl Reader<'_> {
/// Keep part `root` and every part it reaches through its relationships. False when `root`
/// itself can't be kept.
pub(super) fn keep_part_tree(&mut self, root: &str) -> bool {
fn keep_part_tree(&mut self, root: &str) -> bool {
if self.embedded.parts.contains_key(root) {
return true;
}
+3 -267
View File
@@ -1,5 +1,4 @@
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes
//! drawn with them.
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
@@ -8,10 +7,6 @@ use crate::xml::El;
/// Straight pieces a Bézier curve is flattened into.
const CURVE_STEPS: usize = 8;
/// Points read from one path segment. A conformant `a:lnTo`/`a:moveTo` has one, `a:quadBezTo`
/// two and `a:cubicBezTo` three; the cap bounds the de Casteljau control polygon so a segment
/// stuffed with points can't make curve flattening quadratic in the point count.
const MAX_SEG_POINTS: usize = 8;
/// Largest path coordinate kept, in EMUs.
const MAX_EMU: f64 = 1.0e12;
@@ -46,7 +41,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
break;
}
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).take(MAX_SEG_POINTS).collect();
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
match seg.local() {
"moveTo" => {
if !cur.is_empty() {
@@ -70,10 +65,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
}
}
if !cur.is_empty() {
// A path drawn back to where it started is closed, though it has no `a:close` (#743:
// Word's cover pages), so it's filled.
let back = cur.len() > 2 && cur.first() == cur.last();
done.push((cur, back));
done.push((cur, false));
}
for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) {
paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none });
@@ -83,229 +75,6 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
(!f.paths.is_empty()).then_some(f)
}
/// Largest VML path coordinate kept (coordinate-space units).
const MAX_VML: f64 = 1.0e12;
/// Characters of a VML `path` attribute read; the rest is ignored.
const MAX_VML_PATH: usize = 1 << 20;
/// Parameters of a VML path kept, all commands together.
const MAX_VML_PARAMS: usize = MAX_POINTS * 8;
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19).
pub struct VmlPath {
pub freeform: Freeform,
/// `nf` in the path: no fill.
pub no_fill: bool,
/// `ns` in the path: no outline.
pub no_stroke: bool,
}
/// The VML path commands that are two letters long.
const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"];
/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize`
/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute /
/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no
/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight
/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty
/// parameter is 0. `None` when the path has no points, or uses formulas (`@n`, from `v:formulas`,
/// which aren't evaluated): such a shape isn't drawn rather than drawn wrong.
pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option<VmlPath> {
if path.contains('@') {
return None;
}
let pair = |v: Option<&str>| -> Option<(f64, f64)> {
let v = v?;
let (a, b) = v.split_once(',').unwrap_or((v, ""));
let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML));
Some((n(a)?, n(b).unwrap_or(0.0)))
};
let size = |v: f64| if v > 0.0 { v } else { 1000.0 };
let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b)));
let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0));
let (sx, sy) = (w as f64 / cw, h as f64 / ch);
let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] };
let mut d = Draw::default();
let (mut no_fill, mut no_stroke) = (false, false);
for (cmd, params) in vml_commands(path) {
if d.full() {
break;
}
let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec();
match cmd.as_str() {
"m" | "t" => {
for (i, &p) in pts.iter().enumerate() {
let p = if cmd == "t" { d.rel(p) } else { p };
if i == 0 {
d.move_to(p);
} else {
d.line_to(p);
}
}
}
"l" | "r" | "qx" | "qy" => {
for &p in &pts {
let p = if cmd == "r" { d.rel(p) } else { p };
d.line_to(p);
}
}
"c" | "v" => {
for c in pts.as_chunks::<3>().0 {
let start = d.at;
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect();
for i in 1..=CURVE_STEPS {
d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
}
}
}
"qb" => {
if let Some(&p) = pts.last() {
d.line_to(p);
}
}
// Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there.
"at" | "ar" | "wa" | "wr" => {
for [_, _, s, e] in pts.as_chunks::<4>().0 {
if matches!(cmd.as_str(), "ar" | "wr") {
d.move_to(*s);
} else {
d.line_to(*s);
}
d.line_to(*e);
}
}
"x" => d.end(true),
"e" => d.end(false),
"nf" => no_fill = true,
"ns" => no_stroke = true,
_ => {}
}
}
d.end(false);
let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect();
let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
(!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke })
}
/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being
/// drawn and those done (closed or not).
#[derive(Default)]
struct Draw {
at: [f64; 2],
start: [f64; 2],
cur: Vec<[f64; 2]>,
done: Vec<(Vec<[f64; 2]>, bool)>,
}
impl Draw {
fn full(&self) -> bool {
self.done.len() >= MAX_PATHS
}
fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] {
[(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)]
}
fn move_to(&mut self, p: [f64; 2]) {
self.end(false);
self.at = p;
self.start = p;
self.cur.push(p);
}
fn line_to(&mut self, p: [f64; 2]) {
if self.cur.is_empty() {
self.cur.push(self.at);
self.start = self.at;
}
if self.cur.len() < MAX_POINTS {
self.cur.push(p);
}
self.at = p;
}
/// Finish the subpath being drawn, closed back to its start or not.
fn end(&mut self, closed: bool) {
if !self.cur.is_empty() && !self.full() {
self.done.push((std::mem::take(&mut self.cur), closed));
}
self.cur.clear();
if closed {
self.at = self.start;
}
}
}
/// The commands of a VML path with their parameters: a command is a letter (or one of
/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing
/// before a comma) is 0, and so is a formula reference (`@n`, not expected here: see
/// [`vml_path`]); fractions are dropped. Anything else is skipped.
fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
let mut out: Vec<(String, Vec<f64>)> = Vec::new();
let mut chars = path.chars().take(MAX_VML_PATH).peekable();
// Whether a number was read since the command or the last comma.
let mut has_num = true;
let mut kept = 0usize;
let mut push = |out: &mut Vec<(String, Vec<f64>)>, v: f64| {
if kept < MAX_VML_PARAMS
&& let Some((_, p)) = out.last_mut()
{
p.push(v);
kept += 1;
}
};
while let Some(c) = chars.next() {
if c.is_ascii_alphabetic() {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
let lower = c.to_ascii_lowercase();
let mut cmd = lower.to_string();
if let Some(&n) = chars.peek() {
let two: String = [lower, n.to_ascii_lowercase()].iter().collect();
if VML_TWO.contains(&two.as_str()) {
chars.next();
cmd = two;
}
}
out.push((cmd, Vec::new()));
} else if c == ',' {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
} else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() {
let mut v = c.to_digit(10).map_or(0.0, f64::from);
let mut frac = c == '.';
while let Some(&n) = chars.peek() {
if n == '.' {
frac = true;
} else if n.is_ascii_digit() {
if !frac {
v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML);
}
} else {
break;
}
chars.next();
}
let v = match c {
'@' => 0.0,
'-' => -v,
_ => v,
};
has_num = true;
push(&mut out, v);
}
// Whitespace and anything else only separate numbers.
}
if !has_num {
push(&mut out, 0.0);
}
out
}
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
/// `Ink Highlighter 4`).
pub fn ink_tool(name: &str) -> Option<InkTool> {
@@ -346,36 +115,3 @@ fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
}
p.first().copied().unwrap_or([0.0, 0.0])
}
#[cfg(test)]
mod tests {
use super::*;
fn sppr(seg: &str) -> crate::xml::El {
let xml = format!(
"<wps:spPr xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \
xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">\
<a:custGeom><a:pathLst><a:path w=\"100\" h=\"100\">\
<a:moveTo><a:pt x=\"0\" y=\"0\"/></a:moveTo>{seg}</a:path></a:pathLst></a:custGeom></wps:spPr>"
);
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
}
#[test]
fn a_bezier_segment_with_many_points_is_bounded() {
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
// control polygon is capped, so flattening stays a handful of points and returns at once.
let pts = "<a:pt x=\"1\" y=\"1\"/>".repeat(50_000);
let sppr = sppr(&format!("<a:cubicBezTo>{pts}</a:cubicBezTo>"));
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert!(f.paths.iter().all(|p| p.pts.len() <= 1 + CURVE_STEPS), "{:?}", f.paths.iter().map(|p| p.pts.len()).collect::<Vec<_>>());
}
#[test]
fn a_conformant_cubic_still_flattens_to_a_curve() {
let sppr = sppr("<a:cubicBezTo><a:pt x=\"0\" y=\"100\"/><a:pt x=\"100\" y=\"100\"/><a:pt x=\"100\" y=\"0\"/></a:cubicBezTo>");
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert_eq!(f.paths.len(), 1);
assert!(f.paths[0].pts.len() >= 2 && f.paths[0].pts.len() <= 1 + CURVE_STEPS);
}
}
+5 -17
View File
@@ -12,20 +12,6 @@ pub fn read_omath(e: &El, jc: MathJc) -> Math {
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
}
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
/// is kept: the writer replaces it only when the colour changes.
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
let (mut shared, mut uniform) = (None, true);
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared {
None => shared = Some(r.color),
Some(c) => uniform &= c == r.color,
});
let c = shared.flatten().filter(|_| uniform)?;
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
Some(c)
}
/// `m:oMathParaPr/m:jc`.
pub fn para_jc(para: &El) -> MathJc {
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
@@ -233,9 +219,11 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
});
}
// Tracked insertions show; deletions don't.
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => {
if depth <= MAX_DEPTH {
for c in k.els() {
node(c, d, out);
}
}
}
"w:sdt" => {
+57 -230
View File
@@ -5,7 +5,7 @@ pub(crate) mod diagram;
mod drawing_color;
mod embed;
mod freeform;
pub(crate) mod math;
mod math;
mod props;
mod sdt;
mod shape_style;
@@ -20,12 +20,9 @@ use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use crate::DocxError;
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
@@ -34,14 +31,8 @@ use crate::xml::El;
pub(crate) use props::PropCtx;
use story::StoryCtx;
/// Most fonts read from a font table, and the longest name (hostile files).
const MAX_TABLE_FONTS: usize = 2000;
const MAX_FONT_NAME: usize = 256;
/// Most parts (headers, notes, comments, text boxes) we create from one file.
const MAX_PARTS: usize = 50_000;
/// Deepest chain of headers/footers whose own sections reference further headers/footers.
const MAX_HF_DEPTH: usize = 4;
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
/// charts and diagrams; later ones are left empty.
const MAX_GRAPHIC_WORK: usize = 2_000_000;
@@ -71,8 +62,6 @@ pub(crate) struct Reader<'p> {
pub moves: MoveCtx,
media_by_path: HashMap<String, String>,
hf_by_path: HashMap<String, u32>,
/// Header/footer parts being read right now (see [`MAX_HF_DEPTH`]).
hf_depth: usize,
pub footnotes: HashMap<i64, u32>,
pub endnotes: HashMap<i64, u32>,
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
@@ -150,7 +139,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
moves: MoveCtx::default(),
media_by_path: HashMap::new(),
hf_by_path: HashMap::new(),
hf_depth: 0,
footnotes: HashMap::new(),
endnotes: HashMap::new(),
current_note: None,
@@ -173,11 +161,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
// The font table and the fonts embedded through it ride along as opaque parts.
if let Some(p) = part(rt::FONT_TABLE) {
r.keep_part_tree(&p);
r.read_font_families(&p);
}
if let Some(p) = part(rt::THEME) {
r.read_theme(&p);
}
@@ -210,9 +193,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
}
if let Some(p) = part(rt::COMMENTS) {
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
lenient("comments", r.read_comments(&p, ex.as_deref()));
}
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
@@ -243,25 +224,13 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
r.doc.settings.page_color = Some(bg);
}
// A picture or texture fill: `w:background/v:background/v:fill` with a relationship id.
if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) {
let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid"));
if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) {
r.doc.settings.page_picture = Some(PagePicture {
media,
fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(),
title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(),
});
}
}
if let Some(body) = root.child("w:body") {
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
r.flush_pending(&mut sc, &mut blocks);
toc_field_to_heading(&mut blocks);
r.doc.body = blocks;
unstyle_toc_links(&mut r.doc);
toc_field_to_heading(&mut r.doc.body);
if let Some(s) = body.child("w:sectPr") {
r.doc.last_section = r.read_section(s, &rels);
}
@@ -269,9 +238,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
r.read_core(&core);
if let Some(app) = root_rels.by_type(rt::APP).map(|x| x.target.clone()) {
r.read_app(&app);
}
if let Some(c) = root_rels.by_type(rt::CUSTOM)
&& let Ok(Some(x)) = r.xml(&c.target)
{
@@ -287,7 +253,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
r.read_vba_related(&v);
}
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&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));
@@ -420,23 +385,6 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
});
}
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
/// those runs so they look the same here; the links stay.
fn unstyle_toc_links(doc: &mut Document) {
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
if is_toc(&f.instr) {
let _ = doc.format_range(&f.start, &f.end, &|c| {
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") {
c.style = None;
}
});
}
}
}
/// Normalize only a complete TOC range that the engine's heading/entry representation can
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
fn toc_field_to_heading(body: &mut Blocks) {
@@ -448,17 +396,9 @@ fn toc_field_to_heading(body: &mut Blocks) {
}
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
InlineObject::Field {
instr: instr.clone(),
result: String::new(),
locked: *locked,
dirty: *dirty,
code: false,
form: String::new(),
},
Some(toc_range_end(body, i)?),
),
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
}
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
@@ -594,19 +534,6 @@ impl Reader<'_> {
}
}
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
fn read_font_families(&mut self, path: &str) {
let Ok(Some(table)) = self.xml(path) else { return };
self.doc.settings.roman_fonts = table
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
.collect();
}
fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
self.pkg.xml(path)
}
@@ -674,7 +601,7 @@ impl Reader<'_> {
if let Some(id) = self.hf_by_path.get(path) {
return Some(*id);
}
if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH {
if self.doc.parts.len() >= MAX_PARTS {
return None;
}
// Reserve the id first so a (malformed) self-reference can't recurse.
@@ -687,11 +614,8 @@ impl Reader<'_> {
};
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
@@ -734,9 +658,6 @@ impl Reader<'_> {
if let Some(f) = face("a:minorFont") {
self.doc.settings.minor_font = f;
}
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
self.pc.major_cs = cs("a:majorFont");
self.pc.minor_cs = cs("a:minorFont");
}
self.pc.major_font = self.doc.settings.major_font.clone();
self.pc.minor_font = self.doc.settings.minor_font.clone();
@@ -785,8 +706,6 @@ impl Reader<'_> {
}
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
let Some(root) = self.xml(path)? else { return Ok(()) };
// The default paragraph style must be called `Normal` in our model.
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
@@ -811,8 +730,6 @@ impl Reader<'_> {
if sheet.default_chr.size.is_none() {
sheet.default_chr.size = Some(10.0);
}
// Built-in styles' behaviour (gallery, priority, visibility), written back as is.
sheet.latent_styles = root.child("w:latentStyles").map(|l| l.to_xml()).filter(|x| x.len() <= MAX_LATENT_STYLES_XML);
for s in root.children("w:style").take(10_000) {
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
let id = self.pc.style_id(raw_id);
@@ -839,15 +756,6 @@ impl Reader<'_> {
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
&& !matches!(
(kind, id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
),
..Default::default()
};
if let Some(p) = s.child("w:pPr") {
@@ -862,29 +770,6 @@ impl Reader<'_> {
}
sheet.styles.push(st);
}
// Styles that give only a list level take the list from the style they are based on
// (Heading 3: level 3 of Heading 2's outline list).
let fixed: Vec<Option<wordcraft_doc::props::NumRef>> = sheet
.styles
.iter()
.map(|s| match s.para.numbering {
Some(n) if n.num == props::LEVEL_ONLY => {
let base = s.based_on.as_deref().and_then(|b| props::chain_list(&sheet, b));
base.map(|num| wordcraft_doc::props::NumRef { num, level: n.level })
}
other => other,
})
.collect();
for (s, n) in sheet.styles.iter_mut().zip(fixed) {
s.para.numbering = n;
}
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
sheet.default_para.numbering = None;
}
// A producer that never writes `w:qFormat` (LibreOffice) still gets Word's usual gallery.
if !sheet.styles.iter().any(|s| s.quick) {
sheet.add_default_gallery();
}
self.doc.styles = sheet;
Ok(())
}
@@ -902,38 +787,56 @@ impl Reader<'_> {
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr").take(64) {
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
let tc = c.child("w:tcPr");
let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0);
let f = PartFormat {
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
align: ppr.as_ref().and_then(|p| p.align),
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
"center" => VAlign::Center,
"bottom" => VAlign::Bottom,
_ => VAlign::Top,
}),
};
if part == TablePart::WholeTable {
// The whole table's paragraph and run properties are the style's own; its
// borders the table borders.
if let Some(p) = ppr {
st.para.overlay(&p);
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
match c.attr("w:type") {
Some("wholeTable") => {
if let Some(p) = c.child("w:pPr") {
st.para.overlay(&self.pc.ppr(p).0);
}
st.chr.overlay(&chr);
if fill.is_some() {
t.fill = fill;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
}
}
st.chr.overlay(&f.chr);
if f.fill.is_some() {
t.fill = f.fill;
Some("firstRow") => {
t.header_fill = fill;
t.header_chr = chr;
t.header_borders = cell_borders;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
Some("band1Horz") => {
t.band_fill = fill;
t.band_chr = chr;
t.band_borders = cell_borders;
}
if f.v_align.is_some() {
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
Some("firstCol") => {
t.first_col_chr = chr;
t.first_col_fill = fill;
t.first_col_borders = cell_borders;
}
} else {
t.set_part(part, f);
Some("lastCol") => {
t.last_col_chr = chr;
t.last_col_fill = fill;
t.last_col_borders = cell_borders;
}
Some("lastRow") => {
t.total_chr = chr;
t.total_fill = fill;
// The top rule is kept on its own (as built-in styles have it); the other edges
// are the region's borders.
t.total_border_top = cell_borders.and_then(|b| b.top);
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
}
Some("band1Vert") => {
t.col_band_fill = fill;
t.col_band_chr = chr;
t.col_band_borders = cell_borders;
}
_ => {}
}
}
t
@@ -955,19 +858,7 @@ impl Reader<'_> {
if let Some(link) = a.child_val("w:numStyleLink") {
links.push((id, link.to_string()));
}
let hex = |v: &str| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit());
abstracts.push(AbstractNum {
id,
name: a.child_val("w:name").map(str::to_string),
levels,
nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase),
tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase),
multi_level_type: a
.child_val("w:multiLevelType")
// `multilevel` is what the writer derives for the nine levels read here.
.filter(|v| matches!(*v, "singleLevel" | "hybridMultilevel"))
.map(str::to_string),
});
abstracts.push(AbstractNum { id, name: a.child_val("w:name").map(str::to_string), levels });
}
let mut nums = Vec::new();
for n in root.children("w:num").take(10_000) {
@@ -1049,9 +940,6 @@ impl Reader<'_> {
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
}
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
@@ -1065,10 +953,7 @@ impl Reader<'_> {
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
}
"w:linkStyles" => s.link_styles = on_off(k),
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
"w:compat" => {
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
.children("w:compatSetting")
@@ -1080,7 +965,6 @@ impl Reader<'_> {
if let Some(m) = mode.and_then(u32_of) {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:defaultTabStop" => {
@@ -1122,16 +1006,6 @@ impl Reader<'_> {
s.endnote_pr = props::note_pr(k, false);
}
"w:documentProtection" => read_protection(k, s),
"w:zoom" => {
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
let fit = match k.attr("w:val") {
Some("fullPage") => Some(ZoomFit::OnePage),
Some("bestFit") => Some(ZoomFit::PageWidth),
Some("textFit") => Some(ZoomFit::TextWidth),
_ => None,
};
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
}
_ => {}
}
}
@@ -1208,23 +1082,7 @@ impl Reader<'_> {
*slot = Some(blocks);
}
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
fn read_app(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let mut kept = String::new();
for e in root.els() {
let name = e.local();
let value = e.text();
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
kept.push_str(&format!("{name}\t{value}\n"));
}
}
if !kept.is_empty() {
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
}
}
fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
fn read_comments(&mut self, path: &str, ext: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let mut by_para: HashMap<String, u32> = HashMap::new();
@@ -1264,7 +1122,6 @@ impl Reader<'_> {
parent: None,
resolved: false,
part,
..Default::default()
},
);
self.comment_map.insert(file_id, id);
@@ -1282,36 +1139,6 @@ impl Reader<'_> {
}
}
}
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
// prefix here, so attributes are found by local name.
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
};
let mut by_durable: HashMap<String, u32> = HashMap::new();
if let Some(ids) = ids
&& let Ok(Some(x)) = self.xml(ids)
{
for e in x.els().filter(|e| e.local() == "commentId") {
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
if let Some(c) = entries.get_mut(&cid) {
c.durable_id = Some(durable.to_ascii_uppercase());
by_durable.insert(durable.to_ascii_uppercase(), cid);
}
}
}
if let Some(cex) = cex
&& let Ok(Some(x)) = self.xml(cex)
{
for e in x.els().filter(|e| e.local() == "commentExtensible") {
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
c.date_utc = Some(date);
}
}
}
self.doc.comments = entries;
Ok(())
}
+23 -100
View File
@@ -9,33 +9,11 @@ use wordcraft_doc::props::{
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
PageVAlign, SectionProps, SectionStart,
SectionProps, SectionStart,
};
use super::drawing_color::adjust_lum;
use crate::units::{int, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
let len = |name: &str| tw(k, name).filter(|v| v.is_finite()).map(|v| v.clamp(-1584.0, 1584.0));
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
wordcraft_doc::props::TextFrame {
w: len("w:w").filter(|v| *v > 0.0),
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
h_exact: k.attr("w:hRule") == Some("exact"),
x: len("w:x").unwrap_or(0.0),
y: len("w:y").unwrap_or(0.0),
x_align: align("w:xAlign", &["left", "center", "right", "inside", "outside"]),
y_align: align("w:yAlign", &["top", "center", "bottom", "inside", "outside"]),
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
}
use crate::xml::El;
/// Context for property reading: theme fonts and colours, and style id aliases.
@@ -43,9 +21,6 @@ use crate::xml::El;
pub struct PropCtx {
pub major_font: String,
pub minor_font: String,
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
pub major_cs: String,
pub minor_cs: String,
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
/// package has none, and then `w:themeColor` falls back to the fixed colour.
pub theme_colors: Vec<Rgb>,
@@ -58,37 +33,13 @@ pub struct PropCtx {
pub theme: Vec<Rgb>,
}
/// A `w:numPr` that gives a level (`w:ilvl`) and no list (`w:numId`): the list is the one the
/// paragraph's style chain uses (ECMA-376 §17.3.1.19). A placeholder while reading, never left in
/// a document (`resolve_list_levels`).
pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain
/// (its own included) that names a list. `None` when no style in the chain has one.
pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> {
sheet.chain(style).iter().rev().filter_map(|s| s.para.numbering).map(|n| n.num).find(|n| *n != LEVEL_ONLY)
}
/// Resolve level-only numbering in `p` against its style chain (`style` when it has none).
pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &mut ParaProps, style: &str) {
if let Some(n) = p.numbering.as_mut().filter(|n| n.num == LEVEL_ONLY) {
match chain_list(sheet, p.style.as_deref().unwrap_or(style)) {
Some(num) => n.num = num,
None => p.numbering = None,
}
}
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
}
fn theme_font(&self, t: &str) -> Option<String> {
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
// names nothing, so the font beside it or the inherited one applies.
let f = if t.ends_with("Bidi") { cs } else { latin };
let f = if t.starts_with("major") { &self.major_font } else { &self.minor_font };
(!f.is_empty()).then(|| f.clone())
}
@@ -238,12 +189,22 @@ impl PropCtx {
_ => {}
}
}
// A complex-script value equal to its plain one is the same as none (see
// `CharProps::overlay`), which keeps documents round-tripping exactly.
if c.size_cs.is_some() && c.size_cs == c.size {
c.size_cs = None;
}
if c.bold_cs.is_some() && c.bold_cs == c.bold {
c.bold_cs = None;
}
if c.italic_cs.is_some() && c.italic_cs == c.italic {
c.italic_cs = None;
}
c
}
/// `w:pPr` (without `w:sectPr`; `w:rPr` is the paragraph mark, returned separately).
pub fn ppr(&self, e: &El) -> (ParaProps, CharProps) {
// (`w:numPr` with a level and no list sets `LEVEL_ONLY`, resolved by the caller.)
let mut p = ParaProps::default();
let mut mark = CharProps::default();
for k in e.els() {
@@ -268,21 +229,13 @@ impl PropCtx {
"w:framePr" => {
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
} else {
p.frame = Some(text_frame(k));
}
}
"w:numPr" => {
let num = k.child_val("w:numId").and_then(u32_of);
let ilvl = k.child_val("w:ilvl").and_then(u32_of);
let level = ilvl.unwrap_or(0).min(8) as u8;
match (num, ilvl) {
(Some(num), _) => p.numbering = Some(NumRef { num, level }),
// A level without a list: the list comes from the based-on style, as in
// a Heading 3 that only says "level 3" of Heading 2's list. Resolved once
// the styles are read (`resolve_list_levels`).
(None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }),
(None, None) => {}
let level = k.child_val("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8;
if let Some(num) = num {
p.numbering = Some(NumRef { num, level });
}
}
"w:pBdr" => p.borders = Some(borders(k)),
@@ -298,14 +251,6 @@ impl PropCtx {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
let auto = |name| k.attr(name).map(|v| matches!(v.trim(), "1" | "true" | "on"));
if let Some(a) = auto("w:beforeAutospacing") {
p.space_before_auto = Some(a);
}
if let Some(a) = auto("w:afterAutospacing") {
p.space_after_auto = Some(a);
}
if let Some(line) = k.attr("w:line") {
// Word reads the rule case-insensitively (`atleast` is seen in the wild).
let rule = k.attr("w:lineRule").unwrap_or("auto").to_ascii_lowercase();
@@ -516,24 +461,12 @@ pub fn trpr(e: &El) -> RowProps {
}
"w:tblHeader" => r.header = on_off(k),
"w:cantSplit" => r.cant_split = on_off(k),
"w:gridBefore" => r.grid_before = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:gridAfter" => r.grid_after = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:wBefore" => r.w_before = gap_width(k),
"w:wAfter" => r.w_after = gap_width(k),
_ => {}
}
}
r
}
/// `w:wBefore`/`w:wAfter` in points: only a width in twips (`dxa`) is kept.
fn gap_width(k: &El) -> Option<f32> {
match k.attr("w:type") {
None | Some("dxa") => tw(k, "w:w").filter(|v| *v >= 0.0).map(|v| v.min(crate::units::MAX_LEN_PT)),
_ => None,
}
}
/// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation.
pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
let mut c = CellProps { span: 1, ..Default::default() };
@@ -556,18 +489,14 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
"w:tcBorders" => c.borders = Some(borders(k)),
"w:shd" => c.shading = pc.shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:hideMark" => c.hide_mark = on_off(k),
"w:tcMar" => c.margins = Some(margins(k)),
"w:textDirection" => {
c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
c.text_direction_set = k.attr("w:val") == Some("lrTb");
}
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:vAlign" => {
c.valign = Some(match k.attr("w:val") {
c.valign = match k.attr("w:val") {
Some("center") => VAlign::Center,
Some("bottom") => VAlign::Bottom,
_ => VAlign::Top,
})
}
}
_ => {}
}
@@ -650,19 +579,14 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
}
"w:footnotePr" => s.footnote_pr = note_pr(k, true),
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
"w:formProt" => s.form_prot = Some(on_off(k)),
"w:titlePg" => s.title_page = on_off(k),
"w:bidi" => s.rtl = on_off(k),
"w:textDirection" => {
s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
s.text_direction_set = k.attr("w:val") == Some("lrTb");
}
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:pgNumType" => {
s.page_num_start = k.attr("w:start").and_then(u32_of);
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
@@ -682,10 +606,9 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
}
"w:vAlign" => {
s.valign = match k.attr("w:val") {
Some("center") => PageVAlign::Center,
Some("both") => PageVAlign::Justify,
Some("bottom") => PageVAlign::Bottom,
_ => PageVAlign::Top,
Some("center") | Some("both") => VAlign::Center,
Some("bottom") => VAlign::Bottom,
_ => VAlign::Top,
}
}
"w:pgBorders" => s.page_borders = Some(borders(k)),
+1 -87
View File
@@ -29,9 +29,7 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
ShapeKind::Ellipse
}
"flowChartOr" => ShapeKind::FlowchartOr,
"triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"rtTriangle" => ShapeKind::RightTriangle,
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
"line" | "lineInv" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
@@ -46,65 +44,6 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
}
}
/// The shape kind drawn for a legacy VML preset shape type `spt` (`o:spt`, the MSOSPT numbers of
/// [MS-ODRAW] §2.4.24): that of the DrawingML preset of the same shape, through [`preset_kind`],
/// so a preset WordCraft has no shape for is drawn as the nearest one; None for shapes with no
/// preset geometry (freeforms, WordArt text, picture frames) and numbers MSOSPT doesn't define.
pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
let prst = match spt {
// A text box's shapetype: a text box even without text, as DrawingML's `txBox`.
202 => return Some(ShapeKind::TextBox),
// Not primitive (freeform), WordArt text shapes, picture frame, host control.
0 | 24..=31 | 75 | 136..=175 | 201 => return None,
// Line and straight connector: corner to corner of the shape's box.
20 | 32 => "line",
2 => "roundRect",
3 => "ellipse",
4 => "diamond",
5 => "triangle",
6 => "rtTriangle",
12 => "star5",
13 => "rightArrow",
21 => "plaque",
23 => "donut",
33 => "bentConnector2",
34 => "bentConnector3",
35 => "bentConnector4",
36 => "bentConnector5",
37 => "curvedConnector2",
38 => "curvedConnector3",
39 => "curvedConnector4",
40 => "curvedConnector5",
58 => "star8",
59 => "star16",
60 => "star32",
62 => "wedgeRoundRectCallout",
63 => "wedgeEllipseCallout",
66 => "leftArrow",
67 => "downArrow",
68 => "upArrow",
69 => "leftRightArrow",
70 => "upDownArrow",
74 => "heart",
92 => "star24",
93 => "stripedRightArrow",
94 => "notchedRightArrow",
106 => "cloudCallout",
110 => "flowChartDecision",
116 => "flowChartTerminator",
120 => "flowChartConnector",
127 => "flowChartExtract",
128 => "flowChartMerge",
130 => "flowChartOnlineStorage",
176 => "flowChartAlternateProcess",
187 => "star4",
// Every other preset is drawn as its box, as `preset_kind` does.
1..=201 => "rect",
_ => return None,
};
Some(preset_kind(prst))
}
/// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint {
@@ -183,11 +122,6 @@ mod tests {
assert_eq!(p.fill, None);
}
#[test]
fn flowchart_or_keeps_its_spokes() {
assert_eq!(preset_kind("flowChartOr"), ShapeKind::FlowchartOr);
}
#[test]
fn explicit_paint_wins_over_the_style() {
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
@@ -232,26 +166,6 @@ mod tests {
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
#[test]
fn vml_presets_map_like_drawingml_presets() {
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(12), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(32), Some(ShapeKind::Line));
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
// star, rounded callout, elbow connector, a bevel (its box).
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
assert_eq!(vml_preset_kind(34), Some(ShapeKind::ElbowConnector));
assert_eq!(vml_preset_kind(84), Some(ShapeKind::Rectangle));
// No preset geometry: freeform, WordArt text, picture frame, host control, out of range.
for spt in [0, 24, 31, 136, 175, 75, 201, 203, u32::MAX] {
assert_eq!(vml_preset_kind(spt), None, "{spt}");
}
}
#[test]
fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
+64 -408
View File
@@ -7,23 +7,18 @@ use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap};
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt};
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb, TextColor};
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb};
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block};
use super::Reader;
use super::props::{sectpr, tcpr, trpr};
use super::shape_style::{preset_kind, shape_paint, vml_color, vml_preset_kind};
use crate::package::{ContentTypes, Rels, rt};
use super::shape_style::{preset_kind, shape_paint, vml_color};
use crate::package::{Rels, rt};
use crate::units::{int, measure, u32_of};
use crate::xml::{El, Node};
use crate::xml::El;
use wordcraft_doc::wordart::TextWarp;
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
const CHARTEX_URI: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
/// Deepest table nesting we build (deeper tables become plain paragraphs).
const MAX_TABLE_DEPTH: usize = 24;
/// Deepest field nesting we track.
@@ -32,8 +27,6 @@ const MAX_FIELD_DEPTH: usize = 32;
const MAX_STORY_DEPTH: usize = 4;
/// Longest field code we keep (Zotero's citation codes embed item data as JSON).
const MAX_INSTR: usize = 2 * 1024 * 1024;
/// Longest legacy form field definition (`w:ffData`) we keep.
const MAX_FORM: usize = 64 * 1024;
/// Inherited run context (from `w:hyperlink`, `w:ins`, `w:del` wrappers).
#[derive(Clone, Default)]
@@ -63,16 +56,12 @@ struct FieldState {
buf: Vec<(Item, CharProps)>,
props: CharProps,
locked: bool,
/// `w:dirty` from the begin marker: the result is stale, Word refreshes it on open.
dirty: bool,
/// Kept as a range field: its result flows into the document as ordinary content between
/// `FieldStart` and `FieldEnd` markers instead of being buffered.
range: bool,
/// The font size of each piece of the code (where it starts in `instr`): an enclosure's
/// style is the size of its symbol and text (see [`wordcraft_doc::eq`]).
sizes: Vec<(usize, Option<f32>)>,
/// A legacy form field's `w:ffData`, as raw XML.
form: String,
}
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
@@ -127,8 +116,6 @@ pub struct StoryCtx {
pending: Vec<InlineObject>,
/// Text-box nesting depth.
pub story_depth: usize,
/// Reading a header or footer (not a text box inside one): where Word keeps its watermark.
pub in_hf: bool,
}
/// Paragraph content under construction.
@@ -176,7 +163,7 @@ fn clean_text(s: &str) -> String {
fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
}
@@ -344,8 +331,7 @@ impl Reader<'_> {
pb.push_obj(o, &CharProps::default());
}
if let Some(ppr) = e.child("w:pPr") {
let (mut props, mark) = self.pc.ppr(ppr);
super::props::resolve_level_only(&self.doc.styles, &mut props, "Normal");
let (props, mark) = self.pc.ppr(ppr);
para.props = props;
para.mark = mark;
// A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del`
@@ -367,14 +353,7 @@ impl Reader<'_> {
// Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties
// before the change, and a change of the list numbering.
if let Some(ch) = ppr.child("w:pPrChange") {
let old = ch
.child("w:pPr")
.map(|o| {
let mut old = self.pc.ppr(o).0;
super::props::resolve_level_only(&self.doc.styles, &mut old, "Normal");
old.formatting()
})
.unwrap_or_default();
let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default();
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
}
if para.props.numbering.is_some()
@@ -399,11 +378,6 @@ impl Reader<'_> {
para.text = pb.text;
para.runs = pb.runs;
para.objects = pb.objects;
// Paragraph boundaries inside an instruction are hidden with the field code.
// The result paragraph still takes a line, even when its cached text is empty.
if sc.fields.iter().any(|f| f.phase == Phase::Instr) && para.plain_text().is_empty() {
para.mark.hidden = Some(true);
}
para.normalize();
para
}
@@ -454,7 +428,7 @@ impl Reader<'_> {
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
"w:hyperlink" => {
let mut c = ctx.clone();
let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.trim().to_string()).filter(|u| !u.is_empty());
let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.clone());
let anchor = k.attr("w:anchor").filter(|a| !a.is_empty());
c.link = match (url, anchor) {
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
@@ -478,7 +452,7 @@ impl Reader<'_> {
"w:fldSimple" => {
let instr = k.attr("w:instr").unwrap_or("").to_string();
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), String::new());
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"));
self.separate_field(sc, pb);
self.read_inline_children(sc, pb, k, rels, ctx, depth + 1);
self.end_field(sc, pb);
@@ -511,11 +485,8 @@ impl Reader<'_> {
}
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
"m:oMath" => {
let mut props = self.run_props_none(ctx);
let mut math = super::math::read_omath(k, Default::default());
if let Some(c) = super::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let props = self.run_props_none(ctx);
let math = super::math::read_omath(k, Default::default());
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
}
@@ -523,20 +494,13 @@ impl Reader<'_> {
let props = self.run_props_none(ctx);
let jc = super::math::para_jc(k);
for m in k.children("m:oMath") {
let mut math = super::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(c) = super::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let math = super::math::read_omath(m, jc);
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
}
}
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
} else if let Some(c) = children_of_choice(k) {
if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
}
@@ -620,9 +584,6 @@ impl Reader<'_> {
}
"w:tab" | "w:ptab" => self.emit_text(sc, pb, "\t", props),
"w:br" => {
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
return;
}
let c = match k.attr("w:type") {
Some("page") => "\u{000C}",
Some("column") => "\u{000E}",
@@ -630,11 +591,7 @@ impl Reader<'_> {
};
self.emit_text(sc, pb, c, props);
}
"w:cr" => {
if !sc.fields.iter().any(|f| f.phase == Phase::Instr) {
self.emit_text(sc, pb, "\n", props);
}
}
"w:cr" => self.emit_text(sc, pb, "\n", props),
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
@@ -648,10 +605,7 @@ impl Reader<'_> {
}
}
"w:fldChar" => match k.attr("w:fldCharType") {
Some("begin") => {
let form = k.child("w:ffData").map(|f| f.to_xml()).filter(|x| x.len() <= MAX_FORM).unwrap_or_default();
self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), form)
}
Some("begin") => self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock")),
Some("separate") => self.separate_field(sc, pb),
Some("end") => self.end_field(sc, pb),
_ => {}
@@ -671,11 +625,6 @@ impl Reader<'_> {
self.emit_obj(sc, pb, o, props);
}
}
"w:contentPart" => {
if let Some(o) = self.read_ink_content_part(k, rels) {
self.emit_obj(sc, pb, o, props);
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.name == "w:footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
@@ -707,17 +656,12 @@ impl Reader<'_> {
pb.push_obj(InlineObject::CommentEnd { id: cid }, props);
}
}
"w:pgNum" => self.emit_obj(
sc,
pb,
InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
props,
),
"w:pgNum" => {
self.emit_obj(sc, pb, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, props)
}
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
}
@@ -784,24 +728,13 @@ impl Reader<'_> {
// ---- fields ----
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, dirty: bool, form: String) {
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool) {
if sc.fields.len() >= MAX_FIELD_DEPTH || sc.ignored_begins > 0 {
sc.ignored_begins += 1;
return;
}
// Fields nested deeper than the cap are ignored (their content flows to the enclosing field).
sc.fields.push(FieldState {
instr,
phase: Phase::Instr,
spilled: false,
buf: Vec::new(),
props,
locked,
dirty,
range: false,
sizes: Vec::new(),
form,
});
sc.fields.push(FieldState { instr, phase: Phase::Instr, spilled: false, buf: Vec::new(), props, locked, range: false, sizes: Vec::new() });
}
/// Whether the field below the innermost one is still collecting its code (a field nested
@@ -824,7 +757,7 @@ impl Reader<'_> {
f.phase = Phase::Result;
if !f.spilled && !nested_in_instr && is_range_instr(&f.instr) {
f.range = true;
let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, f.props.clone());
let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, f.props.clone());
self.emit_obj(sc, pb, obj, &props);
}
}
@@ -845,19 +778,15 @@ impl Reader<'_> {
}
if f.phase == Phase::Instr && !nested_in_instr && is_range_instr(&f.instr) {
// A range field with no result yet.
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
return;
}
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
// picture) can't be a text result: keep the field as a range around it so the picture
// stays in the document and is saved again. The linked source is never read.
// A tracked insertion or deletion inside the result (one the field itself isn't wrapped
// in) keeps the field a range too: a cached text result has one revision at most, so
// each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037).
let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del));
if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) {
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) {
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
for (it, p) in f.buf {
match it {
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
@@ -879,7 +808,7 @@ impl Reader<'_> {
Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }),
None => (f.instr.trim().to_string(), result),
};
let obj = InlineObject::Field { instr, result, locked: f.locked, dirty: f.dirty, code: false, form: f.form };
let obj = InlineObject::Field { instr, result, locked: f.locked, code: false };
if let Some(parent) = sc.fields.last_mut()
&& !parent.spilled
&& parent.phase == Phase::Instr
@@ -898,11 +827,7 @@ impl Reader<'_> {
continue;
}
f.spilled = true;
let form = std::mem::take(&mut f.form);
pb.push_obj(
InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, dirty: f.dirty, code: false, form },
&f.props,
);
pb.push_obj(InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false }, &f.props);
for (it, p) in f.buf.drain(..) {
match it {
Item::Text(t) => pb.push_text(&t, &p),
@@ -947,7 +872,6 @@ impl Reader<'_> {
let (alt, meta) = nv_alt(c.child("wp:docPr"));
let mut float = if anchored { anchor_float(c) } else { Float::default() };
float.alt = meta;
float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden"));
if let Some(e) = c.child("wp:effectExtent") {
for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) {
// Negative for a rotated object narrower than its frame; settled below.
@@ -957,25 +881,6 @@ impl Reader<'_> {
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
// Checked by URI before the blip search: a diagram's data can hold a blip further down.
let uri = gd.attr("uri").unwrap_or("");
if uri == INK_URI {
let content = gd.child("w14:contentPart")?;
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
let pkg = self.pkg;
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
return None;
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
*e = (*e - p).clamp(0.0, 1584.0);
}
// The renderer's existing missing-picture box marks ink without a usable preview.
// InkML stays opaque: no approximation of its strokes or conversion to freeforms.
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }];
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() });
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
}
let graphic_kind = if uri.ends_with("/chart") {
Some(GraphicKind::Chart)
} else if uri.ends_with("/diagram") {
@@ -987,7 +892,7 @@ impl Reader<'_> {
// Kept so saving writes the chart or diagram back: the frame around it is written
// from the object's (possibly moved or resized) size and position.
let extra: Vec<String> = match kind {
GraphicKind::Chart | GraphicKind::Ink => Vec::new(),
GraphicKind::Chart => Vec::new(),
GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(),
};
let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra));
@@ -1004,14 +909,17 @@ impl Reader<'_> {
} else {
let wsp = gd.find("wps:wsp")?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
&& let Some(tool) = super::freeform::ink_tool(name)
// A freeform: its custom geometry, and whether it's ink WordCraft wrote.
if let InlineObject::Shape { kind, freeform, .. } = &mut obj
&& let Some(sppr) = wsp.child("wps:spPr")
&& let Some(mut f) = super::freeform::cust_geom(sppr, w, h)
{
let f = Arc::make_mut(f);
f.ink = Some(tool);
f.order = super::freeform::ink_order(name);
f.alpha = super::freeform::line_alpha(sppr.child("a:ln"));
let name = c.child("wp:docPr").and_then(|p| p.attr("name"));
f.ink = name.and_then(super::freeform::ink_tool);
f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order);
*kind = ShapeKind::Freeform;
*freeform = Some(Arc::new(f));
}
obj
};
@@ -1030,68 +938,6 @@ impl Reader<'_> {
Some(obj)
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
return None;
}
let fallback = ac.child("mc:Fallback");
let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels));
let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview };
if let InlineObject::Graphic { w, h, graphic, .. } = &mut object {
let graphic = Arc::make_mut(graphic);
if let Some(InlineObject::Image { media, .. }) = preview {
graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }];
}
if let Some(fallback) = fallback {
let kept =
El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
if let Some(source) = self.keep_embedded(&kept, rels, &[]) {
graphic.source = Some(source);
}
// A malformed or over-budget fallback must not discard a valid native part.
}
}
Some(object)
}
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
return None;
}
let fallback = ac.child("mc:Fallback")?;
let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
let source = self.keep_embedded(&kept, rels, &[])?;
let Some(InlineObject::Image { media, w, h, alt, float, .. }) = self.read_drawing(sc, fallback.child("w:drawing")?, rels) else {
return None;
};
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media }];
let graphic = Arc::new(Graphic { kind: GraphicKind::Chart, items, w, h, source: Some(source), ..Default::default() });
Some(InlineObject::Graphic { w, h, alt, float, graphic })
}
fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option<InlineObject> {
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
let pkg = self.pkg;
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
return None;
}
let source = self.keep_embedded(content, rels, &[])?;
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, source: Some(source), ..Default::default() });
Some(InlineObject::Graphic { w: 0.0, h: 0.0, alt: String::new(), float: Float::default(), graphic })
}
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
@@ -1100,7 +946,6 @@ impl Reader<'_> {
let part = match kind {
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)),
GraphicKind::Diagram => self.diagram_part(gd, rels),
GraphicKind::Ink => None,
};
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) };
let key = (kind, path, w.to_bits(), h.to_bits());
@@ -1133,7 +978,6 @@ impl Reader<'_> {
None => Vec::new(),
},
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
GraphicKind::Ink => Vec::new(),
}
}
@@ -1243,9 +1087,8 @@ impl Reader<'_> {
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
let nv = if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") };
let (alt, meta) = nv_alt(nv);
let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() };
let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") });
let float = Float { alt: meta, ..Float::default() };
let obj = if e.name == "pic:pic" {
self.read_pic(e, rels, w, h, alt.clone(), float)
} else {
@@ -1317,25 +1160,8 @@ impl Reader<'_> {
if let Some(a) = bp.attr("anchor") {
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
}
// Internal margins, each defaulting to Word's (#743).
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) {
if let Some(v) = bp.attr(n).and_then(int) {
*slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0);
}
}
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
}
let mut extra = ShapeExtra::default();
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
// rounded rectangles keep the default.
if prst == "roundRect" {
extra.corner = sppr
.and_then(|s| s.child("a:prstGeom"))
.and_then(|g| preset_adj(g, "adj"))
.and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok());
}
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none");
extra.arrow_start = arrow("a:headEnd");
@@ -1353,13 +1179,7 @@ impl Reader<'_> {
self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id));
}
extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp);
// A freeform: its custom geometry (#743: in a group too).
let freeform = sppr.and_then(|s| super::freeform::cust_geom(s, w, h)).map(|mut f| {
f.alpha = super::freeform::line_alpha(ln);
kind = ShapeKind::Freeform;
Arc::new(f)
});
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra }
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra }
}
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
@@ -1380,16 +1200,9 @@ impl Reader<'_> {
self.doc.add_part(PartKind::TextBox, blocks)
}
/// Legacy VML pictures, text boxes, lines, rectangles, ovals and preset shapes (best effort).
/// Legacy VML pictures, text boxes, lines, rectangles and ovals (best effort).
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?;
if sc.in_hf
&& let Some(wm) = vml_watermark(shape)
{
// The document's watermark (the first one found); drawn on every page, not as a shape.
self.doc.settings.watermark.get_or_insert(wm);
return None;
}
let style = shape.attr("style").unwrap_or("");
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
let (mut w, mut h) = (len("width"), len("height"));
@@ -1400,59 +1213,16 @@ impl Reader<'_> {
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
let kind = match shape.local() {
"line" => Some(ShapeKind::Line),
"rect" => Some(ShapeKind::Rectangle),
"roundrect" => Some(ShapeKind::RoundedRectangle),
"oval" => Some(ShapeKind::Ellipse),
// A preset, unless the shape's own path replaces its shapetype's geometry.
"shape" if shape.attr("path").is_none() && shape.child("v:path").and_then(|p| p.attr("v")).is_none() => {
vml_spt(shape, shapetype, type_id).and_then(vml_preset_kind)
}
_ => None,
};
if let Some((tb, t)) = vml_textbox(shape, 0)
let on = |name: &str| shape.attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
if let Some(t) = shape.find("w:txbxContent")
&& sc.story_depth < MAX_STORY_DEPTH
{
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto"));
for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) {
if let Some(pt) = measure(v, 12_700.0) {
*slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32;
}
}
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text");
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
if let Some(a) = vml_style(style, "v-text-anchor") {
part.body.anchor = match a {
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center,
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom,
_ => wordcraft_doc::props::VAlign::Top,
};
}
}
let story = Some(id);
// Text in a preset shape keeps the shape around it; otherwise it is a text box.
let kind = kind.filter(|k| *k != ShapeKind::Rectangle && !k.is_open()).unwrap_or(ShapeKind::TextBox);
let story = Some(self.read_textbox(sc, t, rels));
return Some(InlineObject::Shape {
kind,
kind: ShapeKind::TextBox,
w,
h,
fill,
@@ -1465,46 +1235,16 @@ impl Reader<'_> {
extra: Default::default(),
});
}
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
// or `nf` in the path leaves it unfilled. Not WordArt (`v:textpath`).
if shape.local() == "shape"
&& shape.child("v:textpath").is_none()
&& let Some(path) = shape.attr("path")
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
{
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
let stroke = stroke.filter(|_| !vp.no_stroke);
if stroke.is_none() && fill.is_none() {
return None;
}
let mut f = vp.freeform;
f.alpha = opacity("v:stroke");
return Some(InlineObject::Shape {
kind: ShapeKind::Freeform,
w,
h,
fill,
stroke,
stroke_width,
float,
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: Default::default(),
});
}
let kind = kind?;
let kind = match shape.local() {
"line" => ShapeKind::Line,
"rect" => ShapeKind::Rectangle,
"roundrect" => ShapeKind::RoundedRectangle,
"oval" => ShapeKind::Ellipse,
_ => return None,
};
let mut extra = ShapeExtra::default();
if kind == ShapeKind::Line {
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
extra.arrow_start = arrow("startarrow");
extra.arrow_end = arrow("endarrow");
}
if shape.local() == "line" {
// A line runs `from` → `to` (points, offsets from where its style places it). A preset
// line (`v:shape`) runs corner to corner of the box its style gives it.
// A line runs `from` → `to` (points, offsets from where its style places it).
let pt = |name: &str| -> (f32, f32) {
let v = shape.attr(name).unwrap_or("0,0");
let (x, y) = v.split_once(',').unwrap_or((v, "0"));
@@ -1519,6 +1259,10 @@ impl Reader<'_> {
// Drawn top-left to bottom-right; flipped for the other directions.
float.flip_h ^= x1 < x0;
float.flip_v ^= y1 < y0;
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
extra.arrow_start = arrow("startarrow");
extra.arrow_end = arrow("endarrow");
}
if stroke.is_none() && (fill.is_none() || kind == ShapeKind::Line) {
return None;
@@ -1576,7 +1320,6 @@ impl Reader<'_> {
collect(t, "w:tr", &mut rows, &mut Vec::new(), 0);
for (tr, row_controls) in rows.into_iter().take(MAX_ROWS) {
let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new(), controls: row_controls };
row.props.exceptions = tr.child("w:tblPrEx").map(|e| Box::new(self.pc.tblpr(e)));
if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) {
let old = ch.child("w:trPr").map(trpr).unwrap_or_default();
row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
@@ -1677,13 +1420,7 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) {
fn anchor_float(c: &El) -> Float {
let mut f = Float { wrap: Wrap::Square, ..Float::default() };
let behind = on_off_attr(c, "behindDoc");
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
let kids = c.els().flat_map(|k| match k.name.as_str() {
"mc:AlternateContent" => k.child("mc:Fallback").map(|f| f.els().collect()).unwrap_or_default(),
_ => vec![k],
});
for k in kids {
for k in c.els() {
match k.name.as_str() {
"wp:wrapSquare" => {
f.wrap = Wrap::Square;
@@ -1759,30 +1496,7 @@ fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
wordcraft_doc::wrap::WrapPolygon::new(edited, pts)
}
/// A VML shape's preset type (`o:spt`): its own, its shapetype's, or the number in Word's
/// shapetype id `_x0000_tNN` (`type_id`), Word writing each shapetype only at its first use.
fn vml_spt(shape: &El, shapetype: Option<&El>, type_id: Option<&str>) -> Option<u32> {
let num = |v: &str| v.trim().parse::<u32>().ok();
shape
.attr("o:spt")
.and_then(num)
.or_else(|| shapetype.and_then(|t| t.attr("o:spt")).and_then(num))
.or_else(|| type_id?.strip_prefix("_x0000_t").and_then(num))
}
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded
/// like `El::find`): in a group, a member's margins go with that member's text.
fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
if depth > crate::xml::MAX_DEPTH {
return None;
}
e.els().find_map(|c| match c.child("w:txbxContent") {
Some(t) if c.name == "v:textbox" => Some((c, t)),
_ => vml_textbox(c, depth + 1),
})
}
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
}
@@ -1795,10 +1509,9 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
/// `z-index`. Anything else stays inline.
fn vml_float(shape: &El, style: &str) -> Float {
let get = |k: &str| vml_style(style, k);
let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden"));
if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) {
// Inline: only the turn (an OLE object turned in WordCraft is saved so, #319).
let mut f = Float { hidden, ..Float::default() };
let mut f = Float::default();
f.set_spin(vml_spin(style));
return f;
}
@@ -1845,61 +1558,12 @@ fn vml_float(shape: &El, style: &str) -> Float {
dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")),
dist_top: dist("mso-wrap-distance-top"),
dist_bottom: dist("mso-wrap-distance-bottom"),
hidden,
..Float::default()
};
f.set_spin(vml_spin(style));
f
}
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let tp = shape.child("v:textpath")?;
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
return None;
}
let style = shape.attr("style").unwrap_or("");
let centred = |k: &str| vml_style(style, k).is_some_and(|v| v.eq_ignore_ascii_case("center"));
let word = shape.attr("id").is_some_and(|id| id.starts_with("PowerPlusWaterMarkObject"));
let placed = vml_style(style, "position").is_some_and(|p| p.eq_ignore_ascii_case("absolute"))
&& centred("mso-position-horizontal")
&& centred("mso-position-vertical");
if !word && !placed {
return None;
}
let text: String = tp.attr("string")?.chars().take(200).collect();
if text.trim().is_empty() {
return None;
}
let default = wordcraft_doc::Watermark::default();
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty())
.unwrap_or(default.font);
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0 })
}
/// A VML fraction (`opacity`): a number, a percentage, or 65536ths with an `f` suffix; 0..=1.
fn vml_fraction(v: &str) -> Option<f32> {
let v = v.trim();
let f = if let Some(n) = v.strip_suffix('f') {
n.trim().parse::<f32>().ok()? / 65_536.0
} else if let Some(n) = v.strip_suffix('%') {
n.trim().parse::<f32>().ok()? / 100.0
} else {
v.parse::<f32>().ok()?
};
f.is_finite().then(|| f.clamp(0.0, 1.0))
}
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
/// and/or `y`.
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
@@ -1932,12 +1596,6 @@ fn text_warp(e: &El) -> Option<TextWarp> {
Some(w)
}
/// A preset geometry's adjust value `name` (`a:avLst/a:gd` with `fmla="val N"`).
fn preset_adj(geom: &El, name: &str) -> Option<i64> {
let gd = geom.child("a:avLst")?.children("a:gd").find(|g| g.attr("name") == Some(name))?;
gd.attr("fmla")?.trim().strip_prefix("val")?.trim().parse::<i64>().ok()
}
fn effect_list(l: &El) -> ShapeEffects {
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
let shadow = l.child("a:outerShdw").map(|e| {
@@ -1991,9 +1649,7 @@ struct Xfrm {
/// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6:
/// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk.
fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin {
let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else {
return wordcraft_geom::Spin::default();
};
let Some(x) = sppr.and_then(|s| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() };
let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0);
wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV"))
}
+1 -1
View File
@@ -1,5 +1,5 @@
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`.
//! 2003 XML Reference Schemas). Import only.
//!
//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`,
//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds
-921
View File
@@ -1,921 +0,0 @@
//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
//! Office 2003 XML Reference Schemas).
//!
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
//!
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
//! floating position (pictures become inline), content controls (their content stays), math,
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
//! 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 base64::Engine as _;
use wordcraft_doc::Document;
use crate::flat::W2003_NS;
use crate::package::{Package, Rels, rt};
use crate::xml::{El, MAX_DEPTH, Node};
use crate::{DocxError, Flavor};
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
/// Most pictures written (the reader takes as many).
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories written.
const MAX_STORIES: usize = 50_000;
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
const MAX_PICT_PT: f64 = 31_680.0;
/// Write `doc` as a Word 2003 XML Document.
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
let pkg = Package::from_parts(parts);
let doc_part = pkg
.rels("_rels/.rels")
.by_type(rt::OFFICE_DOC)
.map(|r| r.target.clone())
.filter(|t| pkg.get(t).is_some())
.unwrap_or_else(|| "word/document.xml".to_string());
let mut c = Conv::new(&pkg, &doc_part);
let body = match pkg.xml(&doc_part)? {
Some(root) => {
let rels = c.doc_rels.clone();
match root.child("w:body") {
Some(b) => c.kids(b, &rels, "w:body", 1),
None => Vec::new(),
}
}
None => Vec::new(),
};
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
if let Some(f) = c.default_fonts() {
kids.push(Node::El(f));
}
if let Some(l) = c.lists() {
kids.push(Node::El(l));
}
kids.push(Node::El(c.styles()));
kids.push(Node::El(plain(
"w:docPr",
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
)));
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
let attrs = [
("xmlns:w", W2003_NS),
("xmlns:wx", AUX_NS),
("xmlns:o", OFFICE_NS),
("xmlns:v", VML_NS),
("xmlns:w10", W10_NS),
("xmlns:aml", AML_NS),
("w:macrosPresent", "no"),
("w:embeddedObjPresent", "no"),
("w:ocxPresent", "no"),
("xml:space", "preserve"),
];
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&root.to_xml());
Ok(s.into_bytes())
}
fn plain(name: &str, kids: Vec<Node>) -> El {
El { name: name.into(), attrs: Vec::new(), kids }
}
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
}
fn text_el(name: &str, text: &str) -> El {
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
}
/// `o:DocumentProperties` from the core properties.
fn core_props(doc: &Document) -> El {
let c = &doc.core;
let mut kids = Vec::new();
let revision = c.revision.to_string();
for (name, v) in [
("o:Title", c.title.as_str()),
("o:Subject", c.subject.as_str()),
("o:Author", c.creator.as_str()),
("o:Keywords", c.keywords.as_str()),
("o:Description", c.description.as_str()),
("o:LastAuthor", c.last_modified_by.as_str()),
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
("o:Created", c.created.as_str()),
("o:LastSaved", c.modified.as_str()),
("o:Category", c.category.as_str()),
] {
if !v.trim().is_empty() {
kids.push(Node::El(text_el(name, v.trim())));
}
}
plain("o:DocumentProperties", kids)
}
struct Conv<'a> {
pkg: &'a Package,
doc_rels: Rels,
footnotes: HashMap<String, El>,
endnotes: HashMap<String, El>,
comments: HashMap<String, El>,
foot_rels: Rels,
end_rels: Rels,
comment_rels: Rels,
styles_part: Option<El>,
numbering_part: Option<El>,
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
media: HashMap<String, String>,
/// `w:binData` elements waiting for the `w:pict` being written.
bins: Vec<El>,
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
ann_ids: HashMap<(char, String), i64>,
next_ann: i64,
next_shape: i64,
stories: usize,
in_note: bool,
}
impl<'a> Conv<'a> {
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
let doc_rels = pkg.rels(doc_part);
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
let map = pkg
.xml(&path)
.ok()
.flatten()
.map(|root| {
root.els()
.filter(|e| e.name == local)
.filter(|e| e.attr("w:type").is_none())
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
.collect()
})
.unwrap_or_default();
(map, pkg.rels(&path))
};
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
Conv {
pkg,
styles_part: load(rt::STYLES),
numbering_part: load(rt::NUMBERING),
doc_rels,
footnotes,
endnotes,
comments,
foot_rels,
end_rels,
comment_rels,
media: HashMap::new(),
bins: Vec::new(),
ann_ids: HashMap::new(),
next_ann: 0,
next_shape: 1025,
stories: 0,
in_note: false,
}
}
/// The annotation id for `(kind, id)`: the same each time it is asked.
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
return *n;
}
let n = self.next_ann;
self.next_ann = self.next_ann.saturating_add(1);
self.ann_ids.insert((kind, id.to_string()), n);
n
}
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let mut out = Vec::with_capacity(e.kids.len());
for n in &e.kids {
match n {
Node::Text(t) => out.push(Node::Text(t.clone())),
Node::El(k) => out.extend(self.node(k, rels, parent, depth + 1)),
}
}
out
}
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
if depth > MAX_DEPTH {
return Vec::new();
}
let name = e.name.as_str();
match name {
// Content controls: the content stays, the control goes.
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:drawing" => return self.picture(e, rels),
// A VML fallback is kept for what the modern drawing could not be.
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:object" | "w:txbxContent" => return Vec::new(),
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
"w:commentReference" => return self.comment(e, depth),
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
if matches!(parent, "w:rPr" | "w:trPr") {
return Vec::new();
}
return self.revision(e, rels, parent, depth);
}
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
"w:tblLook" => {
return match e.attr("w:val") {
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
None => Vec::new(),
};
}
_ => {}
}
if !allowed(name) {
return Vec::new();
}
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
if name == "v:imagedata" {
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
el.attrs.retain(|(k, _)| k != "src");
el.attrs.push(("src".into(), src));
} else {
return Vec::new();
}
}
el.kids = self.kids(e, rels, name, depth);
vec![Node::El(el)]
}
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
fn bin_for(&mut self, target: &str) -> Option<String> {
if let Some(n) = self.media.get(target) {
return Some(n.clone());
}
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
return None;
}
let bytes = self.pkg.get(target)?;
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let bin = El {
name: "w:binData".into(),
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
kids: vec![Node::Text(b64)],
};
self.bins.push(bin);
self.media.insert(target.to_string(), name.clone());
Some(name)
}
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
};
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
/// The `v:shape` holding a picture's `v:imagedata`.
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
let mut shape = attr_el(
"v:shape",
&[
("id", id),
("style", style),
("coordsize", "21600,21600"),
("o:spt", "75"),
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
("filled", "f"),
("stroked", "f"),
],
);
if !alt.is_empty() {
shape.attrs.push(("alt".into(), alt.to_string()));
}
let mut data = attr_el("v:imagedata", &[("src", src)]);
data.attrs.push(("o:title".into(), alt.to_string()));
shape.kids.push(Node::El(data));
shape
}
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let kids = self.kids(e, rels, "w:pict", depth);
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
all.extend(kids);
plain("w:pict", all)
}
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut a = Vec::new();
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
a.push(("w:dest".to_string(), r.target.clone()));
}
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
if let Some(v) = e.attr(from) {
a.push((to.to_string(), v.to_string()));
}
}
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
}
/// A footnote or endnote reference as the note itself, inline in its run.
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
let foot = e.name == "w:footnoteReference";
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return 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(&note, &rels, name, depth);
self.in_note = false;
vec![Node::El(plain(name, kids))]
}
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
let mut a = vec![("aml:id".to_string(), id.to_string())];
a.append(&mut extra);
a.push(("w:type".into(), kind.into()));
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
}
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
self.stories += 1;
let rels = self.comment_rels.clone();
let n = self.ann_id('c', id);
self.in_note = true;
let content = self.kids(&c, &rels, "w:comment", depth);
self.in_note = false;
let mut extra = Vec::new();
if let Some(v) = c.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:initials") {
extra.push(("w:initials".to_string(), v.to_string()));
}
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
}
fn range_marker(&mut self, e: &El) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
let (kind, ty) = match e.name.as_str() {
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
_ => ('b', "Word.Bookmark.End"),
};
let n = self.ann_id(kind, id);
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
vec![Node::El(self.annotation(ty, n, extra, None))]
}
/// Tracked insertions and deletions (moves become a deletion and an insertion).
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
let mut extra = Vec::new();
if let Some(v) = e.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = e.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
let content = self.kids(e, rels, parent, depth);
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
}
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut stories = Vec::new();
let mut rest = Vec::new();
for k in e.els() {
if k.name == "w:headerReference" || k.name == "w:footerReference" {
let footer = k.name == "w:footerReference";
if self.stories >= MAX_STORIES {
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",
};
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);
self.in_note = was;
let mut story = plain(name, kids);
story.attrs.push(("w:type".into(), ty.into()));
stories.push(Node::El(story));
} else {
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
}
}
stories.extend(rest);
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
}
/// `w:fonts/w:defaultFonts` from the document defaults.
fn default_fonts(&self) -> Option<El> {
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
let a = attrs(f);
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
}
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
/// default paragraph style, as 2003 has no `docDefaults`.
fn styles(&mut self) -> El {
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
let defaults = root.child("w:docDefaults");
let default_of = |part: &str, block: &str| -> Vec<El> {
defaults
.and_then(|d| d.child(part))
.and_then(|p| p.child(block))
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
.unwrap_or_default()
};
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for st in root.children("w:style") {
let mut st = st.clone();
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
fold(&mut st, "w:pPr", &def_p);
fold(&mut st, "w:rPr", &def_r);
}
kids.extend(self.node(&st, &rels, "w:styles", 2));
}
plain("w:styles", kids)
}
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
fn lists(&mut self) -> Option<El> {
let root = self.numbering_part.clone()?;
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for abs in root.children("w:abstractNum") {
let Some(id) = abs.attr("w:abstractNumId") else { continue };
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
for k in abs.els() {
match k.name.as_str() {
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:multiLevelType" => {
let v = match k.attr("w:val").unwrap_or("") {
"singleLevel" => "SingleLevel",
"hybridMultilevel" => "HybridMultilevel",
_ => "Multilevel",
};
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
}
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
_ => {}
}
}
kids.push(Node::El(def));
}
for num in root.children("w:num") {
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
for o in num.children("w:lvlOverride") {
list.kids.extend(self.node(o, &rels, "w:num", 3));
}
kids.push(Node::El(list));
}
(!kids.is_empty()).then(|| plain("w:lists", kids))
}
}
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
fn fold(style: &mut El, name: &str, defaults: &[El]) {
if defaults.is_empty() {
return;
}
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
let idx = match pos {
Some(i) => i,
None => {
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
let at = if name == "w:pPr" {
style
.kids
.iter()
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
} else {
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
}
.unwrap_or(style.kids.len());
style.kids.insert(at, Node::El(plain(name, Vec::new())));
at
}
};
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
add.append(&mut block.kids);
block.kids = add;
}
}
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
fn list_pr(e: &El) -> El {
let mut kids = Vec::new();
if let Some(v) = e.child_val("w:ilvl") {
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
}
if let Some(v) = e.child_val("w:numId") {
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
}
plain("w:listPr", kids)
}
/// `w:numFmt` → `w:nfc` (a number format code).
fn nfc(e: &El) -> El {
let code = match e.attr("w:val").unwrap_or("decimal") {
"upperRoman" => "1",
"lowerRoman" => "2",
"upperLetter" => "3",
"lowerLetter" => "4",
"ordinal" => "5",
"cardinalText" => "6",
"ordinalText" => "7",
"chicago" => "9",
"decimalFullWidth" => "14",
"decimalHalfWidth" => "15",
"decimalEnclosedCircle" => "18",
"decimalZero" => "22",
"bullet" => "23",
"ganada" => "24",
"chosung" => "25",
"decimalEnclosedFullstop" => "26",
"decimalEnclosedParen" => "27",
"none" => "255",
_ => "0",
};
attr_el("w:nfc", &[("w:val", code)])
}
/// A number without a pointless fraction (`36`, `10.5`).
fn trim_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
fn allowed(name: &str) -> bool {
match name.split_once(':') {
Some(("w", local)) => allowed_w(local),
Some(("v" | "o" | "w10", _)) => true,
_ => false,
}
}
fn allowed_w(local: &str) -> bool {
matches!(
local,
"p" | "pPr"
| "r"
| "rPr"
| "t"
| "tab"
| "br"
| "cr"
| "noBreakHyphen"
| "softHyphen"
| "sym"
| "pgNum"
| "delText"
| "instrText"
| "fldChar"
| "fldSimple"
| "footnoteRef"
| "endnoteRef"
| "annotationRef"
| "separator"
| "continuationSeparator"
| "tbl"
| "tblPr"
| "tblGrid"
| "gridCol"
| "tr"
| "trPr"
| "tc"
| "tcPr"
| "sectPr"
| "hdr"
| "ftr"
| "pStyle"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "framePr"
| "widowControl"
| "suppressLineNumbers"
| "pBdr"
| "shd"
| "tabs"
| "suppressAutoHyphens"
| "kinsoku"
| "wordWrap"
| "overflowPunct"
| "topLinePunct"
| "autoSpaceDE"
| "autoSpaceDN"
| "bidi"
| "adjustRightInd"
| "snapToGrid"
| "spacing"
| "ind"
| "contextualSpacing"
| "mirrorIndents"
| "textAlignment"
| "textDirection"
| "textboxTightWrap"
| "outlineLvl"
| "jc"
| "divId"
| "top"
| "left"
| "bottom"
| "right"
| "between"
| "bar"
| "insideH"
| "insideV"
| "tl2br"
| "tr2bl"
| "rStyle"
| "rFonts"
| "b"
| "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "vanish"
| "webHidden"
| "color"
| "w"
| "kern"
| "position"
| "sz"
| "szCs"
| "highlight"
| "u"
| "effect"
| "bdr"
| "fitText"
| "vertAlign"
| "rtl"
| "cs"
| "em"
| "lang"
| "eastAsianLayout"
| "specVanish"
| "tblStyle"
| "tblpPr"
| "tblOverlap"
| "bidiVisual"
| "tblStyleRowBandSize"
| "tblStyleColBandSize"
| "tblW"
| "tblCellSpacing"
| "tblInd"
| "tblBorders"
| "tblLayout"
| "tblCellMar"
| "cantSplit"
| "trHeight"
| "tblHeader"
| "gridBefore"
| "gridAfter"
| "wBefore"
| "wAfter"
| "hidden"
| "tcW"
| "gridSpan"
| "hMerge"
| "vMerge"
| "tcBorders"
| "noWrap"
| "tcMar"
| "tcFitText"
| "vAlign"
| "pgSz"
| "pgMar"
| "paperSrc"
| "pgBorders"
| "lnNumType"
| "pgNumType"
| "cols"
| "col"
| "formProt"
| "noEndnote"
| "titlePg"
| "rtlGutter"
| "docGrid"
| "type"
| "style"
| "name"
| "aliases"
| "basedOn"
| "next"
| "link"
| "autoRedefine"
| "semiHidden"
| "qFormat"
| "locked"
| "tblStylePr"
| "lvl"
| "start"
| "lvlRestart"
| "isLgl"
| "suff"
| "lvlText"
| "legacy"
| "lvlJc"
| "lvlOverride"
| "startOverride"
| "ilvl"
| "binData"
)
}
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
fn rename(name: &str) -> String {
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
let new = match local {
"eastAsia" => "fareast",
"vMerge" => "vmerge",
"hMerge" => "hmerge",
"bCs" => "b-cs",
"iCs" => "i-cs",
"szCs" => "sz-cs",
"hAnsi" => "h-ansi",
"firstLine" => "first-line",
"lineRule" => "line-rule",
"hRule" => "h-rule",
"linePitch" => "line-pitch",
"firstLineChars" => "first-line-chars",
"hangingChars" => "hanging-chars",
"leftChars" => "left-chars",
"rightChars" => "right-chars",
other => other,
};
format!("w:{new}")
}
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
fn is_toggle(local: &str) -> bool {
matches!(
local,
"b" | "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "snapToGrid"
| "vanish"
| "webHidden"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "widowControl"
| "suppressLineNumbers"
| "suppressAutoHyphens"
| "wordWrap"
| "contextualSpacing"
| "mirrorIndents"
| "rtl"
| "cs"
| "specVanish"
| "tblHeader"
| "cantSplit"
| "noWrap"
| "hidden"
| "semiHidden"
| "qFormat"
| "locked"
| "tcFitText"
| "isLgl"
| "bidi"
| "overflowPunct"
| "kinsoku"
| "autoSpaceDE"
| "autoSpaceDN"
| "adjustRightInd"
| "topLinePunct"
| "titlePg"
)
}
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
/// other vocabularies (`w14:`, `r:`…) are dropped.
fn attrs(e: &El) -> Vec<(String, String)> {
let local_el = e.name.strip_prefix("w:").unwrap_or("");
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
let mut out = Vec::new();
for (k, v) in &e.attrs {
let key = match k.split_once(':') {
Some(("w", l)) => {
let lower = l.to_ascii_lowercase();
if lower.starts_with("rsid") || lower.contains("theme") {
continue;
}
rename(k)
}
Some(("xml", "space")) => k.clone(),
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
None if vml => k.clone(),
_ => continue,
};
let mut val = v.clone();
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
val = switch(&val);
}
out.push((key, val));
}
out
}
/// An on/off value in 2003's spelling.
fn switch(v: &str) -> String {
match v {
"1" | "true" | "on" => "on".into(),
"0" | "false" | "off" => "off".into(),
other => other.into(),
}
}
+8 -90
View File
@@ -1,4 +1,4 @@
//! Writing back charts, SmartArt diagrams, native ink and OLE objects read from a file (see `read::embed`).
//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`).
//!
//! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the
//! parts it needs. The first object to use a part writes it under its original name (unless that
@@ -14,10 +14,9 @@ use wordcraft_doc::para::Float;
use super::PartRels;
use crate::package::{
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED,
rel_is, relative, rt,
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
};
use crate::units::{MAX_LEN_PT, emu, int};
use crate::units::emu;
use crate::xml::{self, El, MAX_DEPTH};
/// Most bytes written for copies of parts (objects pasted more than once).
@@ -61,8 +60,6 @@ impl<'d> EmbedWriter<'d> {
"word/endnotes.xml",
"word/comments.xml",
"word/commentsextended.xml",
"word/commentsids.xml",
"word/commentsextensible.xml",
"[content_types].xml",
]
.iter()
@@ -93,14 +90,6 @@ impl<'d> EmbedWriter<'d> {
}
}
/// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`.
pub fn font_table(&mut self, rels: &mut PartRels) {
let mut written = Vec::new();
if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) {
rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false);
}
}
/// Reserve the names of the media files this writer writes.
pub fn reserve_media<'a>(&mut self, files: impl Iterator<Item = &'a String>) {
for f in files {
@@ -111,7 +100,7 @@ impl<'d> EmbedWriter<'d> {
/// Names this writer may give its own parts later (headers, footers, package parts).
fn reserved(lower: &str) -> bool {
lower.ends_with(".rels")
|| (lower.starts_with("docprops/") && !lower.starts_with("docprops/thumbnail."))
|| lower.starts_with("docprops/")
|| lower.starts_with("_rels/")
|| ((lower.starts_with("word/header") || lower.starts_with("word/footer")) && lower.ends_with(".xml"))
}
@@ -196,26 +185,6 @@ impl<'d> EmbedWriter<'d> {
self.first = memo;
}
/// Reattach the parts kept by `keep_unmodelled` (`UNMODELLED_ROOTS`) to their owner: the
/// package (`P`) or the main document (`D`).
pub fn unmodelled_rels(&mut self, owner: &str, rels: &mut PartRels) {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS).cloned() else { return };
let mut memo = std::mem::take(&mut self.first);
let mut written = Vec::new();
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
let fields: Vec<&str> = line.split('\t').collect();
let [source, kind, target] = fields.as_slice() else { continue };
if *source != owner {
continue;
}
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
let source_part = if owner == "P" { "" } else { STORY_PART };
rels.add(kind, &relative(source_part, &name), false);
}
}
self.first = memo;
}
/// What copying the parts reached from `roots` costs against the copy budget.
fn tree_cost(&self, roots: &[&str]) -> u64 {
let mut seen: HashSet<&str> = HashSet::new();
@@ -282,12 +251,6 @@ impl super::Writer<'_> {
/// the object's current size and position into its own markup (a chart or diagram's frame is
/// written by the caller). `None` when it can't be written back (its parts are gone).
pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<String> {
self.embedded_tree(src, rels, resize).map(|root| root.to_xml())
}
/// The same bounded preservation operation, retaining the parsed tree for a caller that
/// must rebuild an ink AlternateContent drawing frame without reparsing unbound prefixes.
pub(super) fn embedded_tree(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option<El> {
let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else {
log::warn!("docx: too much embedded-object markup; the rest is left out");
return None;
@@ -342,7 +305,7 @@ impl super::Writer<'_> {
if let Some((w, h, float, alt)) = resize {
self.resize_object(&mut root, w, h, float, alt, 0);
}
Some(root)
Some(root.to_xml())
}
/// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points:
@@ -474,51 +437,6 @@ impl super::Writer<'_> {
}
}
}
/// Ink's own transform need not fill its outer drawing frame. Scale its original offsets
/// and extents proportionally; an unrelated edit must not stretch the preserved strokes.
pub(super) fn ink_transform(e: &mut El, from: (f32, f32), to: (f32, f32), float: &Float, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if e.name == "w14:xfrm" {
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "rot" | "flipH" | "flipV"));
for (k, v) in super::story::spin_attrs(float.spin()) {
e.attrs.push((k.to_string(), v));
}
let scale = |old: f32, new: f32| if old.is_finite() && old > 0.0 && new.is_finite() { (new.max(0.0) / old) as f64 } else { 1.0 };
let (sx, sy) = (scale(from.0, to.0), scale(from.1, to.1));
let max = MAX_LEN_PT as f64 * 12_700.0;
for c in e.kids.iter_mut().filter_map(|k| match k {
xml::Node::El(c) => Some(c),
_ => None,
}) {
if !matches!(c.name.as_str(), "a:off" | "a:ext") {
continue;
}
for (key, value) in &mut c.attrs {
let factor = match key.as_str() {
"x" | "cx" => sx,
"y" | "cy" => sy,
_ => continue,
};
if factor != 1.0
&& let Some(n) = int(value)
{
let min = if c.name == "a:ext" { 0.0 } else { -max };
*value = ((n as f64 * factor).clamp(min, max).round() as i64).to_string();
}
}
}
return;
}
for c in e.kids.iter_mut().filter_map(|k| match k {
xml::Node::El(c) => Some(c),
_ => None,
}) {
Self::ink_transform(c, from, to, float, depth + 1);
}
}
}
/// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points.
@@ -609,9 +527,9 @@ fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
.split(';')
.filter(|d| {
let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase();
!(d.trim().is_empty()
|| matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
|| (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")))
!d.trim().is_empty()
&& !matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
&& !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top"))
})
.map(|d| d.trim().to_string())
.collect();
+2 -32
View File
@@ -3,32 +3,12 @@
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
use crate::xml::W;
/// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML
/// is written back unchanged when the equation came from a file. `color` (the equation's run
/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467).
pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option<Rgb>) {
let tinted;
let math = if color == omml_color(&math.omml) {
math
} else {
// The kept source XML has the old colour baked in: write from the nodes instead.
let mut m = math.clone();
if m.nodes.is_empty() {
m.nodes = parse_linear(linear);
}
if let Some(c) = color {
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
r.color.get_or_insert(c);
});
}
m.omml.clear();
tinted = m;
&tinted
};
/// is written back unchanged when the equation came from a file.
pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math) {
if display {
w.open("m:oMathPara", &[]);
let jc = match math.jc {
@@ -102,16 +82,6 @@ pub fn resolve_numbers(math: &Math, display: bool, counter: &mut u32) -> Option<
changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() })
}
/// The equation colour the kept source OMML carries, as the reader lifts it.
fn omml_color(omml: &str) -> Option<Rgb> {
if omml.is_empty() {
return None;
}
let root = crate::xml::parse(wrap_ns(omml).as_bytes()).ok()?;
let e = root.child("m:oMath")?;
crate::read::math::lift_color(&mut crate::read::math::read_omath(e, MathJc::default()))
}
/// The kept XML with namespace declarations, for validating it on its own.
fn wrap_ns(xml: &str) -> String {
let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect();
+50 -253
View File
@@ -1,21 +1,16 @@
//! [`Document`] → DOCX.
mod embed;
mod settings_merge;
pub(crate) use settings_merge::has_unowned as settings_has_unowned;
mod math;
mod props;
mod sdt;
mod story;
mod watermark;
mod wordart;
use std::collections::{BTreeMap, HashMap};
use wordcraft_doc::ParaProps;
use wordcraft_doc::numbering::{Level, LevelSuffix};
use wordcraft_doc::props::VAlign;
use wordcraft_doc::styles::{Style, StyleKind, TablePart};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::{Blocks, Document, PartKind};
use crate::package::{MAX_VBA_RELATED, VBA_PROJECT_PART, VBA_RELATED, rt, zip_entries};
@@ -86,13 +81,6 @@ pub(crate) struct Writer<'d> {
endnotes: Vec<u32>,
/// Header/footer part id → (file name, is footer).
hf: Vec<(u32, String, bool)>,
/// The headers each slot has so far in body order, with sections' empty slots linked to the
/// ones before (as layout shows them): where a watermark needs a header of its own.
linked_headers: wordcraft_doc::section::HeaderSet,
/// The part id standing for a header written only to hold the watermark (no such part).
wm_header: u32,
/// The watermark shape to put at the start of the next top-level paragraph (a header's first).
pending_watermark: Option<String>,
/// Paragraphs that need a `w14:paraId` (last paragraph of each comment), by address.
para_ids: HashMap<usize, String>,
/// A note reference mark to put at the start of the next paragraph.
@@ -102,7 +90,7 @@ pub(crate) struct Writer<'d> {
/// Writing a TOC heading: its TOC field is held back so the entries become the field's result.
toc_hold_end: bool,
/// The held TOC field (instruction, locked, props), opened in the first entry as Word does.
toc_field: Option<(String, bool, bool, wordcraft_doc::props::CharProps)>,
toc_field: Option<(String, bool, wordcraft_doc::props::CharProps)>,
/// Open the held TOC field at the start of the paragraph being written.
toc_begin_here: bool,
/// Close the open TOC field at the end of the paragraph being written.
@@ -159,9 +147,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
footnotes: Vec::new(),
endnotes: Vec::new(),
hf: Vec::new(),
linked_headers: Default::default(),
wm_header: (0..=u32::MAX).rev().find(|id| !doc.parts.contains_key(id)).unwrap_or(u32::MAX),
pending_watermark: None,
para_ids: HashMap::new(),
pending_mark: None,
current_note: None,
@@ -204,28 +189,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:document", &ns_refs);
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
if pic.is_some() || doc.settings.page_color.is_some() {
let color = doc.settings.page_color.map(|c| c.hex());
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
w.open("w:background", &attrs);
if let Some(p) = pic
&& let Some(file) = wr.media_files.get(&p.media)
{
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
}
w.empty("v:fill", &fa);
w.close("v:background");
}
w.close("w:background");
if let Some(c) = doc.settings.page_color {
w.empty("w:background", &[("w:color", &c.hex())]);
}
w.open("w:body", &[]);
wr.blocks(&mut w, &doc.body, &mut rels, true, 0);
@@ -244,14 +209,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let tag = if footer { "w:ftr" } else { "w:hdr" };
w.open(tag, &ns_refs);
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
// The watermark goes in every header, at the start of its first paragraph.
wr.pending_watermark = match &doc.settings.watermark {
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
_ => None,
};
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
// Always taken: the story ends with a top-level paragraph.
wr.pending_watermark = None;
w.close(tag);
push_part(
&mut entries,
@@ -335,14 +293,12 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
w.open("w:comments", &ns_refs);
for (cid, c) in &doc.comments {
let id = cid.to_string();
let author = annotation_author(doc, &c.author);
let author = if c.author.is_empty() { "Unknown" } else { c.author.as_str() };
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
if !c.date.is_empty() && !doc.settings.remove_date_time {
if !c.date.is_empty() {
a.push(("w:date", c.date.as_str()));
}
if doc.settings.remove_personal_info {
a.push(("w:initials", ANONYMOUS_INITIALS));
} else if !c.initials.is_empty() {
if !c.initials.is_empty() {
a.push(("w:initials", c.initials.as_str()));
}
w.open("w:comment", &a);
@@ -381,7 +337,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
&format!("{CT_WML}commentsExtended+xml"),
PartRels::default(),
);
write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels);
}
rels.add(rt::STYLES, "styles.xml", false);
@@ -399,13 +354,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
&mut entries,
&mut overrides,
"word/settings.xml",
{
let generated = settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref());
match doc.passthrough.get(crate::package::SETTINGS_ORIG) {
Some(orig) => settings_merge::merge(orig, &generated).unwrap_or(generated),
None => generated,
}
},
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref()),
&format!("{CT_WML}settings+xml"),
settings_rels,
);
@@ -419,8 +368,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
PartRels::default(),
);
wr.embeds.font_table(&mut rels);
// Media referenced by some drawing (orphan parts would make the package suspect).
let mut media_types: BTreeMap<String, &str> = BTreeMap::new();
for (key, file) in &wr.media_files {
@@ -436,8 +383,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
wr.embeds.custom_xml_rels("D", &mut rels);
wr.embeds.custom_xml_rels("P", &mut root);
wr.embeds.unmodelled_rels("D", &mut rels);
wr.embeds.unmodelled_rels("P", &mut root);
// Parts of embedded objects and custom XML stores kept from the file.
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
@@ -642,7 +587,7 @@ impl Writer<'_> {
}
fn hf_rel(&mut self, part: u32, footer: bool, rels: &mut PartRels) -> Option<String> {
let kind = if part == self.wm_header && !footer { PartKind::Header } else { self.doc.parts.get(&part)?.kind };
let kind = self.doc.parts.get(&part)?.kind;
if !matches!(kind, PartKind::Header | PartKind::Footer) {
return None;
}
@@ -717,33 +662,27 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
w.close("w:pPrDefault");
}
w.close("w:docDefaults");
if let Some(latent) = s.latent_styles.as_deref() {
w.raw(latent);
}
// Kinds whose default style the file named (`w:default`); the English ids stand in otherwise.
let flagged: Vec<StyleKind> = s.styles.iter().filter(|st| st.default).map(|st| st.kind).collect();
for st in &s.styles {
style_xml(&mut w, st, &flagged);
style_xml(&mut w, st);
}
w.close("w:styles");
w.into_bytes()
}
fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
fn style_xml(w: &mut W, st: &Style) {
let ty = match st.kind {
StyleKind::Paragraph => "paragraph",
StyleKind::Character => "character",
StyleKind::Table => "table",
StyleKind::Numbering => "numbering",
};
let english_default = matches!(
let is_default = matches!(
(st.kind, st.id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
);
let is_default = st.default || (english_default && !flagged.contains(&st.kind));
let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())];
if is_default {
a.push(("w:default", "1"));
@@ -799,52 +738,49 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
}
w.close("w:tblPr");
}
// The conditional formatting parts (ECMA-376 §17.7.6): paragraph and run properties,
// then cell borders, shading and vertical alignment. The whole table's text formatting
// is the style's own (above).
for part in TablePart::ALL {
let f = t.part(part);
let ppr = ParaProps { align: f.align, ..ParaProps::default() };
let has_rpr = props::has_rpr(&f.chr);
// The whole table's borders are its table borders (`w:tblPr` above).
let borders = f.borders.filter(|_| part != TablePart::WholeTable);
let has_tc = f.fill.is_some() || borders.is_some() || f.v_align.is_some();
if !has_rpr && !has_tc && f.align.is_none() {
continue;
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
// and shading.
let cond = |w: &mut W,
ty: &str,
chr: &wordcraft_doc::CharProps,
fill: Option<wordcraft_doc::Rgb>,
borders: Option<wordcraft_doc::props::Borders>| {
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
return;
}
w.open("w:tblStylePr", &[("w:type", part.ooxml())]);
if f.align.is_some() {
w.open("w:pPr", &[]);
props::ppr_inner(w, &ppr, false, None);
w.close("w:pPr");
}
if has_rpr {
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, &f.chr);
props::rpr_inner(w, chr);
w.close("w:rPr");
}
if has_tc {
if fill.is_some() || borders.is_some() {
w.open("w:tcPr", &[]);
if let Some(bs) = borders {
props::borders(w, "w:tcBorders", &bs, Some("w:insideH"), &[]);
if let Some(bs) = &borders {
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
}
if let Some(c) = f.fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &c.hex())]);
}
if let Some(v) = f.v_align {
w.val(
"w:vAlign",
match v {
VAlign::Top => "top",
VAlign::Center => "center",
VAlign::Bottom => "bottom",
},
);
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
w.close("w:tcPr");
}
w.close("w:tblStylePr");
}
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
// The total row's borders, its top edge falling back to the built-in top rule.
let total_borders = match (t.total_borders, t.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
};
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
}
@@ -854,19 +790,9 @@ fn numbering_xml(doc: &Document) -> Vec<u8> {
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:numbering", &ns_refs);
let mut nsids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for a in &doc.numbering.abstracts {
w.open("w:abstractNum", &[("w:abstractNumId", &a.id.to_string())]);
// CT_AbstractNum order: nsid, multiLevelType, tmpl, name… A copied definition repeats
// its source's nsid; only the first keeps it, so Word doesn't take them for one list.
if let Some(n) = a.nsid.as_deref().filter(|n| nsids.insert(n)) {
w.val("w:nsid", n);
}
let derived = if a.levels.len() > 1 { "multilevel" } else { "singleLevel" };
w.val("w:multiLevelType", a.multi_level_type.as_deref().unwrap_or(derived));
if let Some(t) = &a.tmpl {
w.val("w:tmpl", t);
}
w.val("w:multiLevelType", if a.levels.len() > 1 { "multilevel" } else { "singleLevel" });
if let Some(n) = &a.name {
w.val("w:name", n);
}
@@ -962,27 +888,8 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:settings", &ns_refs);
let zoom = s.zoom.unwrap_or(wordcraft_doc::Zoom { percent: 100, fit: None });
let percent = zoom.percent.clamp(10, 500).to_string();
match zoom.fit {
Some(fit) => {
let val = match fit {
wordcraft_doc::ZoomFit::OnePage => "fullPage",
wordcraft_doc::ZoomFit::PageWidth => "bestFit",
wordcraft_doc::ZoomFit::TextWidth => "textFit",
};
w.empty("w:zoom", &[("w:val", val), ("w:percent", &percent)]);
}
None => w.empty("w:zoom", &[("w:percent", &percent)]),
}
// §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings.
if s.remove_personal_info {
w.empty("w:removePersonalInformation", &[]);
}
if s.remove_date_time {
w.empty("w:removeDateAndTime", &[]);
}
if s.page_color.is_some() || s.page_picture.is_some() {
w.empty("w:zoom", &[("w:percent", "100")]);
if s.page_color.is_some() {
w.empty("w:displayBackgroundShape", &[]);
}
if s.mirror_margins {
@@ -1054,13 +961,6 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
w.close(tag);
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
if s.allow_space_of_same_style_in_table {
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
}
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
w.close("w:compat");
@@ -1071,76 +971,6 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
w.into_bytes()
}
/// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0?
pub(crate) fn durable_id_ok(d: &str) -> bool {
d.len() <= 8 && u32::from_str_radix(d, 16).is_ok_and(|v| v != 0 && v < 0x7FFF_FFFF)
}
/// `commentsIds.xml` (paragraph id → durable id) and `commentsExtensible.xml` (durable id → UTC
/// date) for the comments that got paragraph ids. Durable ids read from the file are kept (unique
/// ones); comments without one get the next free id. Word uses them to keep tracking a comment
/// across saves and for its UTC time.
fn write_comment_ids(
doc: &Document,
comment_paras: &[(u32, String)],
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
rels: &mut PartRels,
) {
if comment_paras.is_empty() {
return;
}
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next = 0x1000_0000u32;
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
for (cid, pid) in comment_paras {
let Some(c) = doc.comments.get(cid) else { continue };
let kept = c.durable_id.as_deref().filter(|d| durable_id_ok(d)).map(|d| format!("{:08X}", u32::from_str_radix(d, 16).unwrap_or(0)));
let durable = match kept.filter(|d| !used.contains(d)) {
Some(d) => d,
None => loop {
next = next.saturating_add(1);
let d = format!("{next:08X}");
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
break d;
}
},
};
used.insert(durable.clone());
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
let mut w = W::new();
w.open(
"w16cid:commentsIds",
&[("xmlns:mc", mc), ("xmlns:w16cid", "http://schemas.microsoft.com/office/word/2016/wordml/cid"), ("mc:Ignorable", "w16cid")],
);
for (pid, durable, _) in &rows {
w.empty("w16cid:commentId", &[("w16cid:paraId", pid), ("w16cid:durableId", durable)]);
}
w.close("w16cid:commentsIds");
rels.add(rt::COMMENTS_IDS, "commentsIds.xml", false);
push_part(entries, overrides, "word/commentsIds.xml", w.into_bytes(), &format!("{CT_WML}commentsIds+xml"), PartRels::default());
let mut w = W::new();
w.open(
"w16cex:commentsExtensible",
&[("xmlns:mc", mc), ("xmlns:w16cex", "http://schemas.microsoft.com/office/word/2018/wordml/cex"), ("mc:Ignorable", "w16cex")],
);
for (_, durable, utc) in &rows {
match utc {
Some(d) => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable), ("w16cex:dateUtc", d)]),
None => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable)]),
}
}
w.close("w16cex:commentsExtensible");
rels.add(rt::COMMENTS_EXTENSIBLE, "commentsExtensible.xml", false);
push_part(entries, overrides, "word/commentsExtensible.xml", w.into_bytes(), &format!("{CT_WML}commentsExtensible+xml"), PartRels::default());
}
fn theme_xml(doc: &Document) -> Vec<u8> {
let s = &doc.settings;
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
@@ -1197,28 +1027,8 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
w.into_bytes()
}
/// The author the saved file gives a comment or tracked change when the document removes personal
/// information on save (`w:removePersonalInformation`), as Word does.
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
const ANONYMOUS_INITIALS: &str = "A";
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
/// personal information on save, "Unknown" when it has none.
pub(crate) fn annotation_author<'a>(doc: &Document, author: &'a str) -> &'a str {
if doc.settings.remove_personal_info {
ANONYMOUS_AUTHOR
} else if author.is_empty() {
"Unknown"
} else {
author
}
}
fn core_xml(doc: &Document) -> Vec<u8> {
let c = &doc.core;
// Remove personal information (§17.15.1.70): no creator or last-modified-by in the file.
let personal = |v: &String| if doc.settings.remove_personal_info { String::new() } else { v.clone() };
let (creator, last_modified_by) = (personal(&c.creator), personal(&c.last_modified_by));
let mut w = W::new();
w.open(
"cp:coreProperties",
@@ -1233,10 +1043,10 @@ fn core_xml(doc: &Document) -> Vec<u8> {
for (tag, v) in [
("dc:title", &c.title),
("dc:subject", &c.subject),
("dc:creator", &creator),
("dc:creator", &c.creator),
("cp:keywords", &c.keywords),
("dc:description", &c.description),
("cp:lastModifiedBy", &last_modified_by),
("cp:lastModifiedBy", &c.last_modified_by),
("cp:category", &c.category),
] {
if !v.is_empty() {
@@ -1268,19 +1078,6 @@ fn app_xml(doc: &Document) -> Vec<u8> {
w.leaf("Words", &[], &doc.word_count().to_string());
w.leaf("Paragraphs", &[], &doc.paragraph_count().to_string());
w.leaf("DocSecurity", &[], "0");
// Kept properties, except the personal ones when the file asks Word to remove personal
// information on save.
let private = doc.settings.remove_personal_info;
if let Some(kept) = doc.passthrough.get(crate::package::APP_KEPT) {
for line in String::from_utf8_lossy(kept).lines().take(crate::package::APP_KEPT_PROPS.len()) {
let Some((name, value)) = line.split_once('\t') else { continue };
let Some(name) = crate::package::APP_KEPT_PROPS.iter().find(|n| **n == name) else { continue };
if private && matches!(*name, "Company" | "Manager") {
continue;
}
w.leaf(name, &[], value);
}
}
w.close("Properties");
w.into_bytes()
}
+12 -99
View File
@@ -26,7 +26,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
if let Some(s) = &c.style {
w.val("w:rStyle", s);
}
// Emit only explicit properties so absent values keep inheriting from the style chain.
// The complex-script font is written only when set: without it, Persian/Arabic text keeps
// the style's. Complex-script bold, italic and size default to the plain ones (they come in
// pairs, see `CharProps::overlay`), so text formatted here looks the same in Word.
let mut fonts: Vec<(&str, &str)> = Vec::new();
if let Some(f) = &c.font {
fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]);
@@ -41,9 +43,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
w.empty("w:rFonts", &fonts);
}
toggle(w, "w:b", c.bold);
toggle(w, "w:bCs", c.bold_cs);
toggle(w, "w:bCs", c.bold_cs.or(c.bold));
toggle(w, "w:i", c.italic);
toggle(w, "w:iCs", c.italic_cs);
toggle(w, "w:iCs", c.italic_cs.or(c.italic));
toggle(w, "w:caps", c.caps);
toggle(w, "w:smallCaps", c.small_caps);
toggle(w, "w:strike", c.strike);
@@ -75,7 +77,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
if let Some(s) = c.size {
w.val("w:sz", &half(s));
}
if let Some(s) = c.size_cs {
if let Some(s) = c.size_cs.or(c.size) {
w.val("w:szCs", &half(s));
}
if let Some(h) = c.highlight {
@@ -209,35 +211,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
"w:framePr",
&[("w:dropCap", "drop"), ("w:lines", &n(lines.min(10) as i64)), ("w:wrap", "around"), ("w:vAnchor", "text"), ("w:hAnchor", "text")],
);
} else if let Some(f) = &p.frame {
let (fw, fh, fx, fy) = (f.w.map(twips), f.h.map(twips), twips(f.x), twips(f.y));
let (hs, vs) = (twips(f.h_space), twips(f.v_space));
let mut a: Vec<(&str, &str)> = Vec::new();
if let Some(v) = &fw {
a.push(("w:w", v));
}
if let Some(v) = &fh {
a.push(("w:h", v));
a.push(("w:hRule", if f.h_exact { "exact" } else { "atLeast" }));
}
if f.h_space > 0.0 {
a.push(("w:hSpace", &hs));
}
if f.v_space > 0.0 {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
Some(x) => a.push(("w:xAlign", x)),
None => a.push(("w:x", &fx)),
}
match &f.y_align {
Some(y) => a.push(("w:yAlign", y)),
None => a.push(("w:y", &fy)),
}
w.empty("w:framePr", &a);
}
toggle(w, "w:widowControl", p.widow_control);
if let Some(nr) = p.numbering {
@@ -295,12 +268,7 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:snapToGrid", p.snap_to_grid);
if p.space_before.is_some()
|| p.space_after.is_some()
|| p.line_spacing.is_some()
|| p.space_before_auto.is_some()
|| p.space_after_auto.is_some()
{
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
let before = p.space_before.map(|v| twips(v.max(0.0)));
let after = p.space_after.map(|v| twips(v.max(0.0)));
let line = p.line_spacing.map(|l| match l {
@@ -312,15 +280,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(b) = &before {
a.push(("w:before", b));
}
if let Some(on) = p.space_before_auto {
a.push(("w:beforeAutospacing", if on { "1" } else { "0" }));
}
if let Some(x) = &after {
a.push(("w:after", x));
}
if let Some(on) = p.space_after_auto {
a.push(("w:afterAutospacing", if on { "1" } else { "0" }));
}
if let Some((l, r)) = &line {
a.push(("w:line", l));
a.push(("w:lineRule", r));
@@ -464,12 +426,8 @@ fn tblpr_inner(w: &mut W, t: &TableProps) {
/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`].
pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
if let Some(x) = &r.exceptions {
tblprex(w, x);
}
let change = change.zip(r.fmt_change.as_ref());
let gaps = r.grid_before > 0 || r.grid_after > 0 || r.w_before.is_some() || r.w_after.is_some();
if r.height.is_none() && !r.header && !r.cant_split && !gaps && change.is_none() {
if r.height.is_none() && !r.header && !r.cant_split && change.is_none() {
return;
}
w.open("w:trPr", &[]);
@@ -480,48 +438,7 @@ pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
w.close("w:trPr");
}
/// `w:tblPrEx`: only the properties the row overrides (it takes no style, float or look).
fn tblprex(w: &mut W, t: &TableProps) {
w.open("w:tblPrEx", &[]);
if let Some(p) = t.width_pct {
w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]);
} else if let Some(v) = t.width {
w.empty("w:tblW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]);
}
if let Some(a) = t.align {
w.val("w:jc", align_val(if a == Align::Justify || a == Align::Distribute { Align::Left } else { a }));
}
if let Some(i) = t.indent {
w.empty("w:tblInd", &[("w:w", &twips(i)), ("w:type", "dxa")]);
}
if let Some(b) = &t.borders {
borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
}
if let Some(s) = t.shading {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
}
if t.fixed {
w.empty("w:tblLayout", &[("w:type", "fixed")]);
}
if let Some(m) = &t.cell_margins {
margins(w, "w:tblCellMar", m);
}
w.close("w:tblPrEx");
}
fn trpr_inner(w: &mut W, r: &RowProps) {
// The grid gaps go first, in the schema's order (as Word writes them).
if r.grid_before > 0 {
w.val("w:gridBefore", &n(r.grid_before.into()));
}
if r.grid_after > 0 {
w.val("w:gridAfter", &n(r.grid_after.into()));
}
for (name, width) in [("w:wBefore", r.w_before), ("w:wAfter", r.w_after)] {
if let Some(v) = width {
w.empty(name, &[("w:w", &twips(v)), ("w:type", "dxa")]);
}
}
if r.cant_split {
w.empty("w:cantSplit", &[]);
}
@@ -574,17 +491,13 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
if let Some(m) = &c.margins {
margins(w, "w:tcMar", m);
}
if c.text_direction.is_turned() || c.text_direction_set {
if c.text_direction.is_turned() {
w.val("w:textDirection", c.text_direction.ooxml());
}
match c.valign {
None => {}
Some(VAlign::Top) => w.val("w:vAlign", "top"),
Some(VAlign::Center) => w.val("w:vAlign", "center"),
Some(VAlign::Bottom) => w.val("w:vAlign", "bottom"),
}
if c.hide_mark {
w.empty("w:hideMark", &[]);
VAlign::Top => {}
VAlign::Center => w.val("w:vAlign", "center"),
VAlign::Bottom => w.val("w:vAlign", "bottom"),
}
}
-347
View File
@@ -1,347 +0,0 @@
//! `word/settings.xml` merged into the file's original settings part.
//!
//! The model owns only some settings (`super::settings_xml`). Regenerating the part from the
//! model alone drops everything else Word stores there: compatibility options, rsids, document
//! variables, proofing state, the colour scheme mapping, "remove personal information on save"…
//! So when a file brought settings the model doesn't hold ([`has_unowned`]; the reader keeps its
//! bytes under `SETTINGS_ORIG`), the writer merges instead ([`merge`]): the original root start
//! tag (namespace declarations, `mc:Ignorable`) and every child the model doesn't own are kept
//! byte for byte; owned children come from the generated part (or go, when the model turned
//! them off); owned children the original lacks are inserted at their CT_Settings position; in
//! `w:compat` only the `compatibilityMode` setting is replaced. Kept children that refer to a
//! relationship (`r:id`, e.g. a mail merge data source) are dropped: their relationships aren't
//! carried. Anything unexpected returns `None`, and the generated part is written as before.
/// Children of `w:settings` the model writes (`super::settings_xml`).
const OWNED: &[&str] = &[
"w:removePersonalInformation",
"w:removeDateAndTime",
"w:displayBackgroundShape",
"w:mirrorMargins",
"w:attachedTemplate",
"w:linkStyles",
"w:trackRevisions",
"w:documentProtection",
"w:defaultTabStop",
"w:autoHyphenation",
"w:consecutiveHyphenLimit",
"w:hyphenationZone",
"w:doNotHyphenateCaps",
"w:evenAndOddHeaders",
"w:drawingGridHorizontalSpacing",
"w:drawingGridVerticalSpacing",
"w:characterSpacingControl",
"w:footnotePr",
"w:endnotePr",
"m:mathPr",
];
/// Owned children whose original, when there is one, is kept as is.
const KEEP_ORIGINAL: &[&str] = &["m:mathPr", "w:displayBackgroundShape"];
/// CT_Settings' sequence (ECMA-376 §17.15.1.78), for placing inserted children.
const ORDER: &[&str] = &[
"w:writeProtection",
"w:view",
"w:zoom",
"w:removePersonalInformation",
"w:removeDateAndTime",
"w:doNotDisplayPageBoundaries",
"w:displayBackgroundShape",
"w:printPostScriptOverText",
"w:printFractionalCharacterWidth",
"w:printFormsData",
"w:embedTrueTypeFonts",
"w:embedSystemFonts",
"w:saveSubsetFonts",
"w:saveFormsData",
"w:mirrorMargins",
"w:alignBordersAndEdges",
"w:bordersDoNotSurroundHeader",
"w:bordersDoNotSurroundFooter",
"w:gutterAtTop",
"w:hideSpellingErrors",
"w:hideGrammaticalErrors",
"w:activeWritingStyle",
"w:proofState",
"w:formsDesign",
"w:attachedTemplate",
"w:linkStyles",
"w:stylePaneFormatFilter",
"w:stylePaneSortMethod",
"w:documentType",
"w:mailMerge",
"w:revisionView",
"w:trackRevisions",
"w:doNotTrackMoves",
"w:doNotTrackFormatting",
"w:documentProtection",
"w:autoFormatOverride",
"w:styleLockTheme",
"w:styleLockQFSet",
"w:defaultTabStop",
"w:autoHyphenation",
"w:consecutiveHyphenLimit",
"w:hyphenationZone",
"w:doNotHyphenateCaps",
"w:showEnvelope",
"w:summaryLength",
"w:clickAndTypeStyle",
"w:defaultTableStyle",
"w:evenAndOddHeaders",
"w:bookFoldRevPrinting",
"w:bookFoldPrinting",
"w:bookFoldPrintingSheets",
"w:drawingGridHorizontalSpacing",
"w:drawingGridVerticalSpacing",
"w:displayHorizontalDrawingGridEvery",
"w:displayVerticalDrawingGridEvery",
"w:doNotUseMarginsForDrawingGridOrigin",
"w:drawingGridHorizontalOrigin",
"w:drawingGridVerticalOrigin",
"w:doNotShadeFormData",
"w:noPunctuationKerning",
"w:characterSpacingControl",
"w:printTwoOnOne",
"w:strictFirstAndLastChars",
"w:noLineBreaksAfter",
"w:noLineBreaksBefore",
"w:savePreviewPicture",
"w:doNotValidateAgainstSchema",
"w:saveInvalidXml",
"w:ignoreMixedContent",
"w:alwaysShowPlaceholderText",
"w:doNotDemarcateInvalidXml",
"w:saveXmlDataOnly",
"w:useXSLTWhenSaving",
"w:saveThroughXslt",
"w:showXMLTags",
"w:alwaysMergeEmptyNamespace",
"w:updateFields",
"w:hdrShapeDefaults",
"w:footnotePr",
"w:endnotePr",
"w:compat",
"w:docVars",
"w:rsids",
"m:mathPr",
"w:attachedSchema",
"w:themeFontLang",
"w:clrSchemeMapping",
"w:doNotIncludeSubdocsInStats",
"w:doNotAutoCompressPictures",
"w:forceUpgrade",
"w:captions",
"w:readModeInkLockDown",
"w:smartTagType",
"sl:schemaLibrary",
"w:shapeDefaults",
"w:doNotEmbedSmartTags",
"w:decimalSymbol",
"w:listSeparator",
];
fn rank(name: &str) -> usize {
ORDER.iter().position(|n| *n == name).unwrap_or(ORDER.len())
}
/// A child element of the root: its qualified name and byte span.
struct Child {
name: String,
start: usize,
end: usize,
}
/// 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;
if xml.get(open_end - 2..open_end)? == "/>" {
return None;
}
let close = xml.rfind(&format!("</{root}>"))?;
let mut out = Vec::new();
let mut depth = 0usize;
let mut i = open_end;
let mut cur: Option<(String, usize)> = None;
while i < close {
let rest = xml.get(i..close)?;
let Some(lt) = rest.find('<') else { break };
let at = i + lt;
let tail = xml.get(at..close)?;
if tail.starts_with("<!--") {
i = at + tail.find("-->")? + 3;
continue;
}
if tail.starts_with("<![CDATA[") || tail.starts_with("<!") {
return None;
}
if tail.starts_with("<?") {
i = at + tail.find("?>")? + 2;
continue;
}
let gt = at + tail.find('>')? + 1;
let tag = xml.get(at..gt)?;
if tag.starts_with("</") {
depth = depth.checked_sub(1)?;
if depth == 0 {
let (name, s) = cur.take()?;
out.push(Child { name, start: s, end: gt });
}
} else {
let name: String = tag.get(1..)?.chars().take_while(|c| !c.is_whitespace() && *c != '>' && *c != '/').collect();
let selfclose = tag.ends_with("/>");
if depth == 0 {
if selfclose {
out.push(Child { name, start: at, end: gt });
} else {
cur = Some((name, at));
depth = 1;
}
} else if !selfclose {
depth += 1;
}
}
i = gt;
}
if depth != 0 {
return None;
}
Some((open_end, close, out))
}
/// The original `w:compat` with its `compatibilityMode` taken from the generated one.
fn merge_compat(orig: &str, generated: &str) -> Option<String> {
let mode = |s: &str| -> Option<(usize, usize)> {
let at = s.find("w:name=\"compatibilityMode\"")?;
let tag_start = s.get(..at)?.rfind('<')?;
let tag_end = tag_start + s.get(tag_start..)?.find('>')? + 1;
Some((tag_start, tag_end))
};
match (mode(orig), mode(generated)) {
(Some((a, b)), Some((c, d))) => Some(format!("{}{}{}", orig.get(..a)?, generated.get(c..d)?, orig.get(b..)?)),
(None, Some((c, d))) => {
// No mode in the original: add the generated one before `</w:compat>`.
let close = orig.rfind("</w:compat>")?;
Some(format!("{}{}{}", orig.get(..close)?, generated.get(c..d)?, orig.get(close..)?))
}
_ => Some(orig.to_string()),
}
}
/// Does `original` (a settings part) hold anything the model doesn't write? Then the reader keeps
/// it for [`merge`]. False for parts WordCraft wrote itself, and for anything it can't scan.
pub fn has_unowned(original: &[u8]) -> bool {
let Some(xml) = std::str::from_utf8(original).ok() else { return false };
let Some((_, _, kids)) = children(xml, "w:settings") else { return false };
kids.iter().any(|k| match k.name.as_str() {
"w:zoom" => false,
// Anything in `w:compat` beyond its own tag and the compatibility mode.
"w:compat" => xml.get(k.start..k.end).is_some_and(|c| {
let mode = usize::from(c.contains("w:name=\"compatibilityMode\""));
c.matches("<w:").count().saturating_sub(1 + mode) > 0
}),
n => !OWNED.contains(&n),
})
}
/// The merged settings part, or `None` to fall back to `generated`.
pub fn merge(original: &[u8], generated: &[u8]) -> Option<Vec<u8>> {
let orig = std::str::from_utf8(original).ok()?;
let gen_s = std::str::from_utf8(generated).ok()?;
let (o_open, o_close, o_kids) = children(orig, "w:settings")?;
let (_, _, g_kids) = children(gen_s, "w:settings")?;
// Prefixes the generated children use must be declared by the original root.
let root_tag = orig.get(..o_open)?;
for p in ["w", "m", "r"] {
if g_kids.iter().any(|k| k.name.starts_with(&format!("{p}:"))) && !root_tag.contains(&format!("xmlns:{p}=")) {
return None;
}
}
let g_text = |k: &Child| gen_s.get(k.start..k.end).map(str::to_string);
let mut used = vec![false; g_kids.len()];
let mark = |name: &str, used: &mut Vec<bool>| -> Option<usize> {
let gi = g_kids.iter().enumerate().position(|(i, g)| g.name == name && !used.get(i).copied().unwrap_or(true))?;
if let Some(u) = used.get_mut(gi) {
*u = true;
}
Some(gi)
};
let mut out: Vec<(String, String)> = Vec::new();
for k in &o_kids {
let text = orig.get(k.start..k.end)?;
if KEEP_ORIGINAL.contains(&k.name.as_str()) {
// The model reads these only partly (math settings) or writes them only in one case
// (the background shape flag, with a page colour): the original wins.
mark(&k.name, &mut used);
out.push((k.name.clone(), text.to_string()));
} else if OWNED.contains(&k.name.as_str()) {
if let Some(gi) = mark(&k.name, &mut used) {
out.push((k.name.clone(), g_text(g_kids.get(gi)?)?));
}
} else if k.name == "w:compat" {
let merged = match mark("w:compat", &mut used) {
Some(gi) => merge_compat(text, &g_text(g_kids.get(gi)?)?)?,
None => text.to_string(),
};
out.push((k.name.clone(), merged));
} else if !text.contains("r:id=") {
out.push((k.name.clone(), text.to_string()));
}
}
for (gi, g) in g_kids.iter().enumerate() {
let owned = OWNED.contains(&g.name.as_str()) || g.name == "w:compat";
if used.get(gi).copied().unwrap_or(true) || !owned {
continue;
}
let r = rank(&g.name);
let at = out.iter().position(|(n, _)| rank(n) > r).unwrap_or(out.len());
out.insert(at, (g.name.clone(), g_text(g)?));
}
let mut s = String::with_capacity(orig.len() + 256);
s.push_str(root_tag);
for (_, t) in &out {
s.push_str(t);
}
s.push_str(orig.get(o_close..)?);
Some(s.into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
const GEN: &str = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:zoom w:percent="100"/><w:defaultTabStop w:val="720"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="u" w:val="15"/></w:compat></w:settings>"#;
#[test]
fn odd_or_hostile_originals_fall_back_or_merge_without_panicking() {
let odd = [
"",
"<w:settings",
"<w:settings/>",
"<x:other/>",
r#"<w:settings xmlns:w="w"><w:a><w:b></w:a></w:settings>"#,
r#"<w:settings xmlns:w="w"><![CDATA[x]]></w:settings>"#,
r#"<w:settings xmlns:w="w"></w:x></w:settings>"#,
"<w:settings xmlns:w=\"w\">\u{1F600}<w:rsids/></w:settings>",
r#"<w:settings xmlns:w="w"><!-- c --><?pi x?><w:rsids/></w:settings>"#,
];
for o in odd {
let _ = merge(o.as_bytes(), GEN.as_bytes());
let _ = has_unowned(o.as_bytes());
}
assert!(merge(&[0xff, 0xfe], GEN.as_bytes()).is_none());
// Missing declarations for a prefix the generated part uses: fall back.
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
}
#[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>"#;
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
assert!(out.contains("<w:useFELayout/>") && out.contains("compatibilityMode"), "{out}");
assert!(has_unowned(orig.as_bytes()));
assert!(!has_unowned(GEN.as_bytes()));
}
}
+61 -195
View File
@@ -2,7 +2,7 @@
use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor};
use wordcraft_doc::props::{CharProps, PropChange, Rgb};
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
use wordcraft_doc::table::Table;
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
@@ -74,16 +74,7 @@ impl Writer<'_> {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
w.raw("<w:p/>");
}
}
@@ -216,18 +207,12 @@ impl Writer<'_> {
w.empty(mark, &[]);
w.raw("</w:r>");
}
// A header's watermark, in its first top-level paragraph (not a table cell or text box).
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw(&run);
}
// Taken before the content so a nested story (a text box in an entry) can't claim them.
if std::mem::take(&mut self.toc_begin_here)
&& let Some((instr, locked, dirty, props)) = self.toc_field.take()
&& let Some((instr, locked, props)) = self.toc_field.take()
{
self.rev_open(w, &props);
self.field_start(w, &instr, locked, dirty, "", &props);
self.field_start(w, &instr, locked, &props);
self.rev_close(w, &props);
}
let toc_end = std::mem::take(&mut self.toc_end_here);
@@ -384,9 +369,8 @@ impl Writer<'_> {
fn rev_attrs(&mut self, idx: u32) -> (String, String, String) {
self.next_rev_id += 1;
let r = self.doc.revisions.get(idx as usize);
let author = super::annotation_author(self.doc, r.map_or("", |r| r.author.as_str())).to_string();
// Remove date and time (§17.15.1.69): tracked changes are saved without `w:date`.
let date = if self.doc.settings.remove_date_time { String::new() } else { r.map(|r| r.date.clone()).unwrap_or_default() };
let author = r.map(|r| r.author.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| "Unknown".into());
let date = r.map(|r| r.date.clone()).unwrap_or_default();
(self.next_rev_id.to_string(), author, date)
}
@@ -479,7 +463,7 @@ impl Writer<'_> {
w.empty("w:commentRangeEnd", &[("w:id", &s)]);
w.open("w:r", &[]);
w.open("w:rPr", &[]);
w.val("w:rStyle", comment_reference_style(self.doc));
w.val("w:rStyle", "CommentReference");
w.close("w:rPr");
w.empty("w:commentReference", &[("w:id", &s)]);
w.close("w:r");
@@ -487,19 +471,15 @@ impl Writer<'_> {
}
InlineObject::Equation { linear, display, math } => {
// Automatic equation numbers become text: Word numbers nothing by itself.
let color = match props.color {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
match super::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => super::math::write_equation(w, linear, *display, &m, color),
None => super::math::write_equation(w, linear, *display, math, color),
Some(m) => super::math::write_equation(w, linear, *display, &m),
None => super::math::write_equation(w, linear, *display, math),
}
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props),
InlineObject::FieldStart { instr, locked, dirty } => {
InlineObject::Field { instr, result, locked, .. } => self.field(w, instr, result, *locked, props),
InlineObject::FieldStart { instr, locked } => {
self.rev_open(w, props);
self.field_start(w, instr, *locked, *dirty, "", props);
self.field_start(w, instr, *locked, props);
self.rev_close(w, props);
}
InlineObject::FieldEnd => {
@@ -544,65 +524,38 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
// ink keep their original source and related parts.
// A chart or diagram read from a file: its frame from the object's size and position,
// the graphic inside as read. Ones made some other way have nothing to write, except
// charts and SmartArt with a model (made or edited in WordCraft): their parts are
// written from it.
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
(None, None, None) => None,
let inner = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
// A chart WordCraft can edit: a chart part written from its model.
(Some(spec), _, _) => self.chart_graphic(spec, rels),
(None, Some(spec), _) => self.smart_art_graphic(spec, *gw, *gh, rels),
(None, None, Some(src)) => {
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
inner
}
(None, None, None) => return,
};
let Some(mut root) = root else { return };
let direct_ink = ink && root.name == "w:contentPart";
if ink && !direct_ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let choice = alternate.and_then(|e| e.child("mc:Choice"));
let native = choice.and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
if inner.is_empty() {
return;
}
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if direct_ink {
w.raw(&root.to_xml());
} else {
if fallback.is_some() {
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
Some(picture) => {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
}
w.close("mc:AlternateContent");
}
}
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
};
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&inner);
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
@@ -653,7 +606,7 @@ impl Writer<'_> {
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
@@ -795,11 +748,11 @@ impl Writer<'_> {
}
}
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, props: &CharProps) {
if self.toc_hold_end && is_toc(instr) {
// A TOC heading's field opens in the first entry and closes after the last (see `toc_span`).
self.toc_hold_end = false;
self.toc_field = Some((instr.to_string(), locked, dirty, props.clone()));
self.toc_field = Some((instr.to_string(), locked, props.clone()));
return;
}
if let Some(e) = wordcraft_doc::eq::Enclosure::parse(instr) {
@@ -816,7 +769,7 @@ impl Writer<'_> {
w.close("w:r");
};
self.rev_open(w, props);
self.field_start(w, instr, locked, dirty, form, props);
self.field_start(w, instr, locked, props);
if !result.is_empty() {
run(w, &|w| {
let mut buf = String::new();
@@ -894,9 +847,7 @@ impl Writer<'_> {
}
/// A field's begin, instruction and separate runs.
/// A field's begin, code and separator; `form` is a legacy form field's `w:ffData`, which
/// belongs inside the begin.
fn field_start(&self, w: &mut W, instr: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
fn field_start(&self, w: &mut W, instr: &str, locked: bool, props: &CharProps) {
let del = self.is_del(props);
let run = |w: &mut W, f: &dyn Fn(&mut W)| {
w.open("w:r", &[]);
@@ -905,19 +856,10 @@ impl Writer<'_> {
w.close("w:r");
};
run(w, &|w| {
let mut attrs: Vec<(&str, &str)> = vec![("w:fldCharType", "begin")];
if locked {
attrs.push(("w:fldLock", "1"));
}
if dirty {
attrs.push(("w:dirty", "true"));
}
if form.is_empty() {
w.empty("w:fldChar", &attrs);
w.empty("w:fldChar", &[("w:fldCharType", "begin"), ("w:fldLock", "1")])
} else {
w.open("w:fldChar", &attrs);
w.raw(form);
w.close("w:fldChar");
w.empty("w:fldChar", &[("w:fldCharType", "begin")])
}
});
let instr_text = format!(" {} ", instr.trim());
@@ -932,7 +874,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -964,7 +906,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -991,9 +933,7 @@ impl Writer<'_> {
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::FlowchartOr => "flowChartOr",
ShapeKind::Triangle => "triangle",
ShapeKind::RightTriangle => "rtTriangle",
ShapeKind::Diamond => "diamond",
ShapeKind::Line => "line",
ShapeKind::StraightConnector => "straightConnector1",
@@ -1007,15 +947,7 @@ impl Writer<'_> {
Some(f) => cust_geom(w, f, *sw, *sh),
None => {
w.open("a:prstGeom", &[("prst", prst)]);
match extra.corner.filter(|_| *kind == ShapeKind::RoundedRectangle) {
Some(c) => {
w.open("a:avLst", &[]);
let fmla = format!("val {}", c.min(wordcraft_doc::connector::MAX_CORNER));
w.empty("a:gd", &[("name", "adj"), ("fmla", &fmla)]);
w.close("a:avLst");
}
None => w.empty("a:avLst", &[]),
}
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
}
}
@@ -1087,41 +1019,18 @@ impl Writer<'_> {
if body.anchor != wordcraft_doc::props::VAlign::Top {
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
}
// Internal margins, when the box has its own.
let inset = body.inset.map(|i| i.map(|v| v.min(wordcraft_doc::MAX_TEXT_BOX_INSET).to_string()));
if let Some(inset) = &inset {
for (n, v) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(inset) {
attrs.push((n, v));
match extra.warp.as_ref().and_then(|t| t.sanitized()) {
Some(warp) => {
w.open("wps:bodyPr", &attrs);
text_warp(w, &warp);
w.close("wps:bodyPr");
}
}
let warp = extra.warp.as_ref().and_then(|t| t.sanitized());
if warp.is_none() && !body.auto_fit {
w.empty("wps:bodyPr", &attrs);
} else {
w.open("wps:bodyPr", &attrs);
if let Some(warp) = &warp {
text_warp(w, warp);
}
if body.auto_fit {
w.empty("a:spAutoFit", &[]);
}
w.close("wps:bodyPr");
None => w.empty("wps:bodyPr", &attrs),
}
w.close("wps:wsp");
}
#[allow(clippy::too_many_arguments)]
/// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place.
#[allow(clippy::too_many_arguments)]
fn graphic_frame(&mut self, w: &mut W, float: &Float, (gw, gh): (f32, f32), docpr: &str, name: &str, alt: &str, graphic: &crate::xml::El) {
w.open("w:drawing", &[]);
self.drawing_open(w, float, gw, gh, docpr, name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&graphic.to_xml());
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
}
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
@@ -1201,7 +1110,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt);
}
fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) {
@@ -1263,34 +1172,9 @@ impl Writer<'_> {
w.close("w:tbl");
}
/// The section's headers, plus the header holding only the watermark in each slot the
/// pages show (first page with a title page, even pages with even/odd headers) that has no
/// header of its own or linked from an earlier section. Sections are written in body order.
fn watermark_headers(&mut self, s: &SectionProps) -> wordcraft_doc::section::HeaderSet {
let mut set = s.headers;
let linked = &mut self.linked_headers;
if self.doc.settings.watermark.as_ref().and_then(super::watermark::text).is_some() {
let id = self.wm_header;
if set.default.or(linked.default).is_none() {
set.default = Some(id);
}
if s.title_page && set.first.or(linked.first).is_none() {
set.first = Some(id);
}
if self.doc.settings.even_odd_headers && set.even.or(linked.even).is_none() {
set.even = Some(id);
}
}
linked.default = set.default.or(linked.default);
linked.first = set.first.or(linked.first);
linked.even = set.even.or(linked.even);
set
}
pub fn sectpr(&mut self, w: &mut W, s: &SectionProps, rels: &mut PartRels) {
w.open("w:sectPr", &[]);
let headers = self.watermark_headers(s);
for (set, footer) in [(&headers, false), (&s.footers, true)] {
for (set, footer) in [(&s.headers, false), (&s.footers, true)] {
for (kind, id) in [("default", set.default), ("first", set.first), ("even", set.even)] {
let Some(id) = id else { continue };
if let Some(rid) = self.hf_rel(id, footer, rels) {
@@ -1369,7 +1253,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
if s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
@@ -1396,19 +1280,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
match s.valign {
wordcraft_doc::section::PageVAlign::Top => {}
wordcraft_doc::section::PageVAlign::Center => w.val("w:vAlign", "center"),
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
wordcraft_doc::props::VAlign::Top => {}
wordcraft_doc::props::VAlign::Center => w.val("w:vAlign", "center"),
wordcraft_doc::props::VAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
if s.text_direction.is_turned() || s.text_direction_set {
if s.text_direction.is_turned() {
w.val("w:textDirection", s.text_direction.ooxml());
}
if s.rtl {
@@ -1633,7 +1513,7 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice.
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -1641,9 +1521,6 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
if !alt.title.is_empty() {
attrs.push(("title", alt.title.as_str()));
}
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative {
w.empty(tag, &attrs);
return;
@@ -1685,14 +1562,3 @@ pub(super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
w.val("w:numRestart", r.ooxml());
}
}
/// The character style for comment reference marks: the document's "annotation reference" style
/// (Word's built-in name; localized documents give it another id, e.g. Polish
/// `Odwoaniedokomentarza`), else `CommentReference`.
fn comment_reference_style(doc: &wordcraft_doc::Document) -> &str {
doc.styles
.styles
.iter()
.find(|s| s.kind == wordcraft_doc::styles::StyleKind::Character && s.name.eq_ignore_ascii_case("annotation reference"))
.map_or("CommentReference", |s| s.id.as_str())
}

Some files were not shown because too many files have changed in this diff Show More