mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 17:04:43 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9521cb81c | ||
|
|
62bc097fab |
@@ -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
|
||||
@@ -11,9 +11,6 @@ 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 | ~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") |
|
||||
@@ -66,12 +63,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
|
||||
| `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 |
|
||||
|
||||
Generated
+24
-152
@@ -241,7 +241,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -843,22 +842,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1332,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"
|
||||
@@ -1375,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"
|
||||
@@ -1568,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",
|
||||
@@ -1584,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",
|
||||
@@ -1732,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"
|
||||
@@ -1749,7 +1711,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1758,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]]
|
||||
@@ -2437,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"
|
||||
@@ -2843,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"
|
||||
@@ -2913,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"
|
||||
@@ -3863,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]]
|
||||
@@ -4067,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"
|
||||
@@ -5078,59 +4996,30 @@ 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.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"open",
|
||||
"pollster 0.4.0",
|
||||
"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.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
@@ -5142,11 +5031,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -5158,12 +5047,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5173,7 +5060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -5195,7 +5082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5221,7 +5108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5229,12 +5116,11 @@ dependencies = [
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"wuff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5247,14 +5133,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5273,7 +5159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5285,7 +5171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5293,7 +5179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -5313,7 +5199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5322,7 +5208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5336,9 +5222,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
@@ -5355,13 +5240,12 @@ dependencies = [
|
||||
"wordcraft-layout",
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zbus",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5374,13 +5258,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5411,17 +5294,6 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "wuff"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"bytes",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -5493,7 +5365,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -47,16 +47,11 @@ regex-automata = { version = "0.4", default-features = false, features = ["std",
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
|
||||
# (brotli-decompressor, flate2) are already in the tree.
|
||||
wuff = "0.2.9"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
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"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
aes = "0.9"
|
||||
@@ -73,7 +68,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"
|
||||
|
||||
@@ -23,56 +23,6 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- 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, …)
|
||||
|
||||
@@ -115,9 +115,9 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation |
|
||||
| **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) |
|
||||
| **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 |
|
||||
| **Review** | Spelling and grammar with suggestions, 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 or Български (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 or Русский (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
|
||||
```
|
||||
@@ -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,23 +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`.
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -350,10 +339,8 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
|
||||
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
|
||||
option). The sample documents and templates are original text written for WordCraft.
|
||||
The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II
|
||||
word list. The sample documents and templates are original text written for WordCraft.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
|
||||
+9
-150
@@ -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-13 · **Change:** 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:** trivial (security review fixes landed; 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
|
||||
@@ -16,7 +14,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **482 / 497 (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) |
|
||||
@@ -60,7 +58,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
|
||||
| File formats | 60% | 185–300 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Layout and pagination fidelity | 55% | 90–145 | [layout-parity.md](docs/layout-parity.md) |
|
||||
| Hardware | 40% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 27% | 75–120 + native review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 25% | 80–130 + native review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 75% | 10–20 | [layout-parity.md](docs/layout-parity.md#performance) |
|
||||
| Stability | 50% | 25–45 | [gaps.md](docs/gaps.md) (#3) |
|
||||
| Platforms | 95% (beyond Word on Linux, BSD, web) | 5–10 | this page |
|
||||
@@ -87,7 +85,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
|
||||
| Pictures, shapes, text boxes, WordArt | 60% | 20–30 |
|
||||
| Backstage, printing, options | 58% | 12–20 |
|
||||
| Right-to-left and complex scripts | 50% | 15–25 |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 40% | 20–40 |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 35% | 25–45 |
|
||||
| Accessibility | 35% | 15–25 |
|
||||
| East Asian typography | 25% | 15–25 |
|
||||
| Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 |
|
||||
@@ -104,18 +102,16 @@ 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% |
|
||||
| German | de | none (PR #203 open) | 0% |
|
||||
| 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). 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, Italian and Czech proofing are not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing.
|
||||
Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
|
||||
system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -134,90 +130,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-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-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-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 | 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 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
|
||||
| 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 | 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 | 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-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-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-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-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-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 | 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-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-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-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-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) |
|
||||
| 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog |
|
||||
| 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) |
|
||||
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
|
||||
| 2026-10-11 | Design tab: the style set gallery gets a box and gaps of its own, so it no longer runs into Colors and Fonts (#638) |
|
||||
| 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-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-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 |
|
||||
@@ -227,16 +146,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 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-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 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 |
|
||||
| 2026-10-11 | Add fonts without rebuilding (#582): Home › Font › Add Font… (end of the font list), dropping a TTF/OTF/TTC/WOFF/WOFF2 file, or `font.add`/`font.list`/`font.remove` for agents. Files are checked (50 MB cap, every face must parse and map characters), never replace a bundled or loaded face, and are kept between runs (desktop: `fonts` in the settings folder; web: IndexedDB). Linking a font by URL is still to come |
|
||||
| 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments |
|
||||
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
|
||||
| 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 |
|
||||
@@ -250,23 +166,15 @@ 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 |
|
||||
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
|
||||
| 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged |
|
||||
| 2026-10-12 | Imported TOCs keep actual result boundaries, nested page fields, formatting and locks through DOCX saves without relying on TOC styles; existing engine-managed styled TOCs remain refreshable |
|
||||
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
|
||||
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
|
||||
| 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-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-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-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 |
|
||||
@@ -276,7 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
|
||||
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
|
||||
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released |
|
||||
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
|
||||
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
|
||||
@@ -287,48 +195,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
|
||||
| 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-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-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-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-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 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-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-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) |
|
||||
@@ -339,7 +207,6 @@ 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-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
|
||||
@@ -348,22 +215,15 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 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-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 |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
|
||||
| 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-12 | trivial | Bulgarian interface (`bg`): 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-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-11 | trivial | Progress log: eight more bibliography styles (#383) |
|
||||
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
|
||||
@@ -373,7 +233,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
|
||||
| 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% |
|
||||
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
|
||||
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
//! `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::fmt;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
@@ -14,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)
|
||||
@@ -29,14 +25,8 @@ 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,
|
||||
@@ -52,12 +42,6 @@ fn open(path: &str) -> Result<Session, String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// 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}"))
|
||||
@@ -92,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 },
|
||||
@@ -188,26 +152,13 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
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", ¶ms)
|
||||
s.run("file.save", &json!({"path": o, "markup": markup}))
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
@@ -222,8 +173,7 @@ fn run(args: &[String]) -> 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)?;
|
||||
@@ -315,17 +265,9 @@ fn run(args: &[String]) -> 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());
|
||||
@@ -339,13 +281,6 @@ fn run(args: &[String]) -> 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)?;
|
||||
}
|
||||
@@ -481,12 +416,6 @@ mod tests {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[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());
|
||||
@@ -499,58 +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());
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
@@ -29,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>WordCraft</title>
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/wordcraft.ico" />
|
||||
<link data-trunk rel="rust" data-bin="wordcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e6e6e6; }
|
||||
|
||||
+16
-203
@@ -1,7 +1,7 @@
|
||||
//! 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 _;
|
||||
@@ -9,9 +9,8 @@ use wordcraft_engine::Session;
|
||||
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 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";
|
||||
|
||||
@@ -27,8 +26,7 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = Rc::new(PickerSaves::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();
|
||||
@@ -45,26 +43,14 @@ pub fn start() {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
// 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();
|
||||
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(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
|
||||
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, saves }))
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -86,38 +72,6 @@ fn query() -> String {
|
||||
struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
saves: Rc<PickerSaves>,
|
||||
}
|
||||
|
||||
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
|
||||
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
|
||||
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
|
||||
#[derive(Default)]
|
||||
struct PickerSaves {
|
||||
/// Picker saves started and not yet written and closed.
|
||||
pending: Cell<u32>,
|
||||
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
|
||||
unsaved: RefCell<Vec<(String, Option<String>)>>,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// A picker save that didn't happen leaves the document with unsaved changes.
|
||||
fn settle_picker_saves(&mut self) {
|
||||
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
|
||||
for (name, error) in unsaved {
|
||||
// Only a save in a format that keeps everything marked the document saved.
|
||||
if wordcraft_ui_egui::keeps_everything(&name) {
|
||||
self.app.session.dirty = true;
|
||||
}
|
||||
if let Some(error) = error {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(
|
||||
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
self.app.status(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -137,7 +91,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.settle_picker_saves();
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
@@ -197,17 +150,14 @@ 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: Rc<PickerSaves>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = if purpose == "picture" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS)
|
||||
} else if purpose == "font" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS)
|
||||
} else if purpose == "recipients" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS)
|
||||
} else {
|
||||
@@ -224,32 +174,20 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
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)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
||||
let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Box::new(move || reduce.as_ref().map(|query| !query.matches()))
|
||||
}
|
||||
|
||||
/// 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: Rc<PickerSaves>) -> 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.get() > 0 {
|
||||
if dirty.get() {
|
||||
e.prevent_default();
|
||||
// Older browsers ask only when `returnValue` is set.
|
||||
e.set_return_value("unsaved changes");
|
||||
@@ -261,145 +199,20 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
|
||||
fn download(saves: &Rc<PickerSaves>, 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: &Rc<PickerSaves>,
|
||||
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();
|
||||
saves.pending.set(saves.pending.get().saturating_add(1));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// The document counts as saved only once the file is written and closed.
|
||||
let unsaved = 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");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't write {name}: {e}");
|
||||
Some(Some(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");
|
||||
Some(None)
|
||||
} else {
|
||||
log::error!("showSaveFilePicker error: {e:?}");
|
||||
Some(Some(format!("{e:?}")))
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(error) = unsaved {
|
||||
saves.unsaved.borrow_mut().push((name, error));
|
||||
}
|
||||
saves.pending.set(saves.pending.get().saturating_sub(1));
|
||||
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,116 +0,0 @@
|
||||
//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about
|
||||
//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged
|
||||
//! and skipped: a font that can't be stored still works for the session.
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode};
|
||||
|
||||
const DB_NAME: &str = "wordcraft";
|
||||
const STORE: &str = "fonts";
|
||||
|
||||
fn js(e: JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
|
||||
/// Wait for an IndexedDB request; its result.
|
||||
async fn done(req: &IdbRequest) -> Result<JsValue, String> {
|
||||
let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| {
|
||||
let ok_req = req.clone();
|
||||
let ok = Closure::once_into_js(move || {
|
||||
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let err = Closure::once_into_js(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed"));
|
||||
});
|
||||
req.set_onsuccess(Some(ok.unchecked_ref()));
|
||||
req.set_onerror(Some(err.unchecked_ref()));
|
||||
});
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js)
|
||||
}
|
||||
|
||||
/// The database, with its font store made on first use.
|
||||
async fn open() -> Result<IdbDatabase, String> {
|
||||
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?;
|
||||
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>().map_err(JsValue::from))
|
||||
&& !db.object_store_names().contains(STORE)
|
||||
&& let Err(e) = db.create_object_store(STORE)
|
||||
{
|
||||
log::error!("couldn't make the font store: {e:?}");
|
||||
}
|
||||
});
|
||||
req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
|
||||
let db = done(&req).await;
|
||||
req.set_onupgradeneeded(None);
|
||||
drop(upgrade);
|
||||
db?.dyn_into::<IdbDatabase>().map_err(js)
|
||||
}
|
||||
|
||||
async fn store_async(name: String, bytes: Vec<u8>) -> Result<(), String> {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?;
|
||||
done(&req).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn forget_async(name: String) -> Result<(), String> {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let req = store.delete(&JsValue::from_str(&name)).map_err(js)?;
|
||||
done(&req).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name).
|
||||
pub fn store(name: &str, bytes: &[u8]) {
|
||||
let (name, bytes) = (name.to_string(), bytes.to_vec());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = store_async(name.clone(), bytes).await {
|
||||
log::error!("couldn't keep the font {name} for the next visit: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget the font stored under `name`.
|
||||
pub fn forget(name: &str) {
|
||||
let name = name.to_string();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = forget_async(name.clone()).await {
|
||||
log::error!("couldn't forget the stored font {name}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Add every stored font to the font database (each checked like a new one; one that fails is
|
||||
/// logged and skipped), then repaint: the app lays out again when fonts change.
|
||||
pub fn load_all(ctx: egui::Context) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let r: Result<(), String> = async {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let keys = done(&store.get_all_keys().map_err(js)?).await?;
|
||||
let values = done(&store.get_all().map_err(js)?).await?;
|
||||
let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values));
|
||||
for i in 0..keys.length().min(values.length()) {
|
||||
let Some(name) = keys.get(i).as_string() else { continue };
|
||||
let Ok(bytes) = values.get(i).dyn_into::<js_sys::Uint8Array>() else { continue };
|
||||
if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES {
|
||||
log::warn!("stored font {name} is too large; skipped");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) {
|
||||
log::warn!("stored font {name} skipped: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = r {
|
||||
log::warn!("no stored fonts: {e}");
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -13,9 +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 }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
@@ -34,40 +31,8 @@ winresource = "0.1"
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
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 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"] }
|
||||
|
||||
[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.
|
||||
@@ -77,3 +42,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"
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
//! The system's animation setting, which File › Options › Accessibility › Animation effects
|
||||
//! follows when set to "Use system setting" (`Services::animations_enabled`).
|
||||
//!
|
||||
//! - Windows: Settings › Accessibility › Visual effects › Animation effects, read through the
|
||||
//! Windows Runtime's `UISettings`.
|
||||
//! - macOS: System Settings › Accessibility › Display › Reduce motion, read through `NSWorkspace`.
|
||||
//! - Linux and the BSDs: the desktop's setting, read through the settings portal ([`desktop`]).
|
||||
//!
|
||||
//! All three are safe APIs of crates already in the build (our workspace forbids unsafe). The UI
|
||||
//! asks every frame, so the answer always comes from memory: Windows and macOS are asked again at
|
||||
//! most once a second, and the Linux/BSD side keeps its answer up to date on a thread of its own.
|
||||
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
mod desktop;
|
||||
|
||||
/// The answer to "does the system have animation effects on?" for the UI: `None` when the system
|
||||
/// doesn't say. `ctx` is asked to redraw when an answer that arrives later changes it.
|
||||
pub fn hook(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
{
|
||||
let _ = ctx;
|
||||
Box::new(remembered(system_enabled, REFRESH))
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
Box::new(desktop::watch(ctx.clone()))
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", unix)))]
|
||||
{
|
||||
let _ = ctx;
|
||||
Box::new(|| None)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long an answer from Windows or macOS is kept before the system is asked again.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// `read`, asked at most once per `every` and answered from memory in between.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
fn remembered(read: impl Fn() -> Option<bool>, every: std::time::Duration) -> impl Fn() -> Option<bool> {
|
||||
let last = std::cell::Cell::new(None::<(std::time::Instant, Option<bool>)>);
|
||||
move || {
|
||||
if let Some((at, answer)) = last.get()
|
||||
&& at.elapsed() < every
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let answer = read();
|
||||
last.set(Some((std::time::Instant::now(), answer)));
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Windows has animation effects on; `None` when it can't be asked.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn system_enabled() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new()
|
||||
.and_then(|settings| settings.AnimationsEnabled())
|
||||
.map_err(|e| log::debug!("system animation setting: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Whether macOS plays animations: Reduce motion is off.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn system_enabled() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::remembered;
|
||||
|
||||
/// The UI asks every frame; the system is asked once per interval and the answer kept between.
|
||||
#[test]
|
||||
fn the_system_is_asked_once_per_interval() {
|
||||
let asked = Cell::new(0);
|
||||
let system = Cell::new(Some(false));
|
||||
let read = || {
|
||||
asked.set(asked.get() + 1);
|
||||
system.get()
|
||||
};
|
||||
let kept = remembered(read, Duration::from_secs(3600));
|
||||
assert_eq!((kept(), kept(), kept()), (Some(false), Some(false), Some(false)));
|
||||
system.set(Some(true));
|
||||
assert_eq!(kept(), Some(false), "within the interval the remembered answer stands");
|
||||
assert_eq!(asked.get(), 1);
|
||||
|
||||
// Once the interval has passed (here: at once) the system is asked again.
|
||||
let fresh = remembered(read, Duration::ZERO);
|
||||
assert_eq!(fresh(), Some(true));
|
||||
system.set(None);
|
||||
assert_eq!(fresh(), None);
|
||||
assert_eq!(asked.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
//! The desktop's animation setting on Linux and the BSDs.
|
||||
//!
|
||||
//! Asked of the XDG settings portal over D-Bus (`org.freedesktop.portal.Settings`), which GNOME,
|
||||
//! KDE Plasma and the other desktops with a portal answer, inside Flatpak too. Three settings say
|
||||
//! whether animations play ([`KEYS`]); any one of them saying no turns them off. Where no portal
|
||||
//! answers, or it knows none of the three (a bare window manager), the user's own KDE and GTK
|
||||
//! settings files are read instead, once.
|
||||
//!
|
||||
//! A thread asks at start-up and then follows the portal's change signal, so the UI's question is
|
||||
//! always answered from memory and a change in the desktop's settings shows without a restart.
|
||||
|
||||
/// What a setting holds, whichever way it was stored.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Reading {
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// A setting as the portal names it: (namespace, key).
|
||||
type Key = (&'static str, &'static str);
|
||||
|
||||
/// The cross-desktop preference: 1 asks for reduced motion; 0, and values not defined yet, are
|
||||
/// no preference.
|
||||
const REDUCED_MOTION: Key = ("org.freedesktop.appearance", "reduced-motion");
|
||||
/// GNOME's switch, the one GTK follows.
|
||||
const GNOME_ANIMATIONS: Key = ("org.gnome.desktop.interface", "enable-animations");
|
||||
/// KDE Plasma's animation speed, a factor on every animation's length: 0 makes them instant.
|
||||
const KDE_ANIMATION_SPEED: Key = ("org.kde.kdeglobals.KDE", "AnimationDurationFactor");
|
||||
|
||||
/// The settings that say whether animations play.
|
||||
pub const KEYS: [Key; 3] = [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED];
|
||||
|
||||
/// Whether animations play according to one of [`KEYS`]; `None` for any other setting, or a
|
||||
/// value that isn't one it holds.
|
||||
pub fn says(namespace: &str, key: &str, value: &Reading) -> Option<bool> {
|
||||
match (namespace, key) {
|
||||
k if k == REDUCED_MOTION => number(value).map(|n| n != 1.0),
|
||||
k if k == GNOME_ANIMATIONS => truth(value),
|
||||
k if k == KDE_ANIMATION_SPEED => number(value).map(|n| n > 0.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(value: &Reading) -> Option<f64> {
|
||||
let n = match value {
|
||||
Reading::Number(n) => *n,
|
||||
Reading::Text(t) => t.trim().parse().ok()?,
|
||||
Reading::Bool(_) => return None,
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
fn truth(value: &Reading) -> Option<bool> {
|
||||
match value {
|
||||
Reading::Bool(b) => Some(*b),
|
||||
Reading::Number(n) => n.is_finite().then_some(*n != 0.0),
|
||||
Reading::Text(t) => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" => Some(true),
|
||||
"false" | "0" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Several settings' answers as one: off as soon as one says off, on when at least one says on,
|
||||
/// `None` when none says anything.
|
||||
pub fn combine(answers: impl IntoIterator<Item = Option<bool>>) -> Option<bool> {
|
||||
let mut any = None;
|
||||
for on in answers.into_iter().flatten() {
|
||||
if !on {
|
||||
return Some(false);
|
||||
}
|
||||
any = Some(true);
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of a settings file in INI form (KDE's `kdeglobals`, GTK's
|
||||
/// `settings.ini`). The last one wins, as the toolkits read it.
|
||||
pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
|
||||
let mut inside = false;
|
||||
let mut found = None;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name.trim() == section;
|
||||
} else if inside
|
||||
&& !line.starts_with(['#', ';'])
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
found = Some(v.trim());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// What the user's own settings files say, given their text (`None` for a file that isn't
|
||||
/// there): KDE Plasma's `kdeglobals` and GTK's `settings.ini`s.
|
||||
pub fn files_say(kdeglobals: Option<&str>, gtk_settings: [Option<&str>; 2]) -> Option<bool> {
|
||||
let (namespace, key) = KDE_ANIMATION_SPEED;
|
||||
let kde = kdeglobals.and_then(|text| ini_value(text, "KDE", key)).and_then(|v| says(namespace, key, &Reading::Text(v.to_string())));
|
||||
let gtk = gtk_settings
|
||||
.into_iter()
|
||||
.map(|text| text.and_then(|t| ini_value(t, "Settings", "gtk-enable-animations")).and_then(|v| truth(&Reading::Text(v.to_string()))));
|
||||
combine(std::iter::once(kde).chain(gtk))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub use watching::watch;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod watching {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
use super::{KEYS, Reading, combine, files_say, says};
|
||||
|
||||
// The latest answer, as one byte the UI thread reads without waiting.
|
||||
const UNKNOWN: u8 = 0;
|
||||
const ON: u8 = 1;
|
||||
const OFF: u8 = 2;
|
||||
|
||||
/// Start following the desktop's animation setting. The returned function gives the latest
|
||||
/// answer from memory (`None` until the first one arrives, and when the desktop says nothing);
|
||||
/// `ctx` is asked to redraw when the answer changes.
|
||||
pub fn watch(ctx: egui::Context) -> impl Fn() -> Option<bool> {
|
||||
let latest = Arc::new(AtomicU8::new(UNKNOWN));
|
||||
let shared = latest.clone();
|
||||
let spawned = std::thread::Builder::new().name("animation-setting".into()).spawn(move || {
|
||||
let publish = |answer: Option<bool>| {
|
||||
let code = match answer {
|
||||
Some(true) => ON,
|
||||
Some(false) => OFF,
|
||||
None => UNKNOWN,
|
||||
};
|
||||
if shared.swap(code, Ordering::Relaxed) != code {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
if let Err(e) = follow_portal(&publish) {
|
||||
log::debug!("desktop animation setting: no settings portal ({e}); reading the settings files");
|
||||
publish(read_files());
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("desktop animation setting: {e}");
|
||||
}
|
||||
move || match latest.load(Ordering::Relaxed) {
|
||||
ON => Some(true),
|
||||
OFF => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish what the settings portal says, now and after every change it announces, until the
|
||||
/// connection ends; an error when there is no session bus to ask on. While the portal knows
|
||||
/// none of the settings, the files' answer stands.
|
||||
fn follow_portal(publish: &dyn Fn(Option<bool>)) -> zbus::Result<()> {
|
||||
let connection = Connection::session()?;
|
||||
let portal = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
|
||||
// Listen before the first read, so a change between the two isn't missed.
|
||||
let changes = portal.receive_signal("SettingChanged")?;
|
||||
// The files are read once, the first time the portal has nothing to say.
|
||||
let mut files = None;
|
||||
let mut ask = || ask_portal(&portal).or_else(|| *files.get_or_insert_with(read_files));
|
||||
publish(ask());
|
||||
// A setting can turn up later (KDE's has no value while the speed is at its default), so
|
||||
// the changes are followed whatever the portal knew at first. Every setting is asked
|
||||
// again, not just the one announced: KDE announces a new animation speed only as the
|
||||
// cross-desktop preference, and its own setting must not be left at the old value.
|
||||
for change in changes {
|
||||
let ours = change
|
||||
.body()
|
||||
.deserialize::<(String, String, OwnedValue)>()
|
||||
.is_ok_and(|(namespace, key, _)| KEYS.contains(&(namespace.as_str(), key.as_str())));
|
||||
if ours {
|
||||
publish(ask());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the portal's settings come to; `None` when it has none of them.
|
||||
fn ask_portal(portal: &Proxy<'_>) -> Option<bool> {
|
||||
combine(KEYS.map(|(namespace, key)| read_setting(portal, namespace, key).and_then(|value| says(namespace, key, &value))))
|
||||
}
|
||||
|
||||
/// One setting from the portal; `None` when it doesn't have it.
|
||||
fn read_setting(portal: &Proxy<'_>, namespace: &str, key: &str) -> Option<Reading> {
|
||||
// `ReadOne` is the current call; portals from before it only have `Read`.
|
||||
let value: OwnedValue = portal.call("ReadOne", &(namespace, key)).or_else(|_| portal.call("Read", &(namespace, key))).ok()?;
|
||||
reading(&value, 0)
|
||||
}
|
||||
|
||||
fn reading(value: &Value<'_>, depth: u8) -> Option<Reading> {
|
||||
Some(match value {
|
||||
Value::Bool(b) => Reading::Bool(*b),
|
||||
Value::U8(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I16(n) => Reading::Number(f64::from(*n)),
|
||||
Value::U16(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I32(n) => Reading::Number(f64::from(*n)),
|
||||
Value::U32(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I64(n) => Reading::Number(*n as f64),
|
||||
Value::U64(n) => Reading::Number(*n as f64),
|
||||
Value::F64(n) => Reading::Number(*n),
|
||||
Value::Str(s) => Reading::Text(s.as_str().to_string()),
|
||||
// `Read` wraps its answer in a second variant.
|
||||
Value::Value(inner) if depth < 4 => return reading(inner, depth + 1),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the user's settings files say (see [`files_say`]).
|
||||
fn read_files() -> Option<bool> {
|
||||
let config = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
|
||||
let text = |file: &str| read_capped(&config.join(file));
|
||||
let (kdeglobals, gtk4, gtk3) = (text("kdeglobals"), text("gtk-4.0/settings.ini"), text("gtk-3.0/settings.ini"));
|
||||
files_say(kdeglobals.as_deref(), [gtk4.as_deref(), gtk3.as_deref()])
|
||||
}
|
||||
|
||||
/// A settings file's text: at most 256 KiB of it (they are a few kilobytes).
|
||||
fn read_capped(path: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256 * 1024).read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(s: &str) -> Reading {
|
||||
Reading::Text(s.to_string())
|
||||
}
|
||||
|
||||
/// Each desktop's setting, as the portal hands it over, comes to animations on or off.
|
||||
#[test]
|
||||
fn each_desktop_setting_says_on_or_off() {
|
||||
let ((appearance, reduced), (gnome, enable), (kde, factor)) = (REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED);
|
||||
assert_eq!(KEYS, [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]);
|
||||
// The cross-desktop preference: only 1 asks for reduced motion.
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(1.0)), Some(false));
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(0.0)), Some(true));
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(7.0)), Some(true), "a value not defined yet is no preference");
|
||||
assert_eq!(says(appearance, reduced, &Reading::Bool(true)), None);
|
||||
// GNOME's switch.
|
||||
assert_eq!(says(gnome, enable, &Reading::Bool(false)), Some(false));
|
||||
assert_eq!(says(gnome, enable, &Reading::Bool(true)), Some(true));
|
||||
assert_eq!(says(gnome, enable, &text("nonsense")), None);
|
||||
// KDE Plasma's animation speed arrives as text: 0 is instant.
|
||||
assert_eq!(says(kde, factor, &text("0")), Some(false));
|
||||
assert_eq!(says(kde, factor, &text(" 0.5 ")), Some(true));
|
||||
assert_eq!(says(kde, factor, &Reading::Number(1.0)), Some(true));
|
||||
for junk in [text(""), text("fast"), Reading::Number(f64::NAN), text("inf")] {
|
||||
assert_eq!(says(kde, factor, &junk), None, "{junk:?}");
|
||||
}
|
||||
// Any other setting says nothing.
|
||||
assert_eq!(says(gnome, "color-scheme", &Reading::Bool(false)), None);
|
||||
assert_eq!(says("org.example", enable, &Reading::Bool(false)), None);
|
||||
}
|
||||
|
||||
/// One setting saying off is enough; none saying anything is no answer.
|
||||
#[test]
|
||||
fn any_setting_saying_off_turns_animations_off() {
|
||||
assert_eq!(combine([]), None);
|
||||
assert_eq!(combine([None, None]), None);
|
||||
assert_eq!(combine([None, Some(true)]), Some(true));
|
||||
assert_eq!(combine([Some(true), None, Some(false)]), Some(false));
|
||||
assert_eq!(combine([Some(false), Some(true)]), Some(false));
|
||||
}
|
||||
|
||||
/// Without a portal the user's own settings files answer: KDE's animation speed and GTK's
|
||||
/// `gtk-enable-animations`.
|
||||
#[test]
|
||||
fn settings_files_answer_without_a_portal() {
|
||||
let kde_instant = "[General]\nColorScheme=Breeze\n\n[KDE]\nAnimationDurationFactor=0\nSingleClick=false\n";
|
||||
let kde_slow = "[KDE]\nAnimationDurationFactor = 2\n";
|
||||
let gtk_off = "[Settings]\ngtk-theme-name=Adwaita\ngtk-enable-animations=0\n";
|
||||
let gtk_on = "# written by hand\n[Settings]\ngtk-enable-animations=true\n";
|
||||
assert_eq!(files_say(None, [None, None]), None);
|
||||
assert_eq!(files_say(Some("[KDE]\nSingleClick=false\n"), [Some("[Settings]\n"), None]), None, "files that don't mention it");
|
||||
assert_eq!(files_say(Some(kde_instant), [None, None]), Some(false));
|
||||
assert_eq!(files_say(Some(kde_slow), [None, None]), Some(true));
|
||||
assert_eq!(files_say(None, [Some(gtk_off), None]), Some(false));
|
||||
assert_eq!(files_say(None, [None, Some(gtk_on)]), Some(true));
|
||||
assert_eq!(files_say(Some(kde_slow), [Some(gtk_on), Some(gtk_off)]), Some(false), "one file saying off is enough");
|
||||
|
||||
// The key counts only in its own section, comments don't, and the last value wins.
|
||||
assert_eq!(ini_value("[Other]\nAnimationDurationFactor=0\n[KDE]\nSingleClick=true\n", "KDE", "AnimationDurationFactor"), None);
|
||||
assert_eq!(ini_value("[KDE]\n#AnimationDurationFactor=0\n;AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None);
|
||||
assert_eq!(ini_value("[KDE]\r\nAnimationDurationFactor=0\r\nAnimationDurationFactor=1\r\n", "KDE", "AnimationDurationFactor"), Some("1"));
|
||||
assert_eq!(ini_value("AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None, "outside any section");
|
||||
assert_eq!(ini_value("", "KDE", "AnimationDurationFactor"), None);
|
||||
}
|
||||
}
|
||||
@@ -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 holding an equation puts HTML (the equation as MathML) beside its text
|
||||
//! (#816), for programs that read MathML (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,183 +0,0 @@
|
||||
//! File › Options › General › Make WordCraft the default for Word documents (#295).
|
||||
//!
|
||||
//! The installers register WordCraft for Word, RTF and ODT files without taking over the
|
||||
//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`).
|
||||
//! This is the button that makes it the default when the user asks:
|
||||
//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's
|
||||
//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query
|
||||
//! and shows the Default apps list), where the user picks WordCraft per type.
|
||||
//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop <types>` for
|
||||
//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is
|
||||
//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak
|
||||
//! sandbox, which can't change the host's defaults.
|
||||
//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All.
|
||||
//!
|
||||
//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// The `Services::make_default_app` hook.
|
||||
pub type Hook = Box<dyn Fn() -> Result<DefaultApp, String>>;
|
||||
|
||||
/// The hook for this platform; `None` where there is no button (macOS).
|
||||
pub fn hook() -> Option<Hook> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hook: Option<Hook> = Some(Box::new(windows::open_default_apps));
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let hook: Option<Hook> = Some(Box::new(xdg::make_default));
|
||||
#[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))]
|
||||
let hook: Option<Hook> = None;
|
||||
hook
|
||||
}
|
||||
|
||||
/// The desktop entry the Linux packages install, and the Flatpak's app id.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||
"application/vnd.ms-word.document.macroEnabled.12", // .docm
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx
|
||||
"application/vnd.ms-word.template.macroEnabled.12", // .dotm
|
||||
"application/msword", // .doc
|
||||
"application/msword-template", // .dot
|
||||
"application/rtf", // .rtf
|
||||
"text/rtf", // .rtf (older name)
|
||||
"application/vnd.oasis.opendocument.text", // .odt
|
||||
];
|
||||
|
||||
/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one
|
||||
/// call sets them all.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
|
||||
["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// 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)))]
|
||||
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();
|
||||
let home_dir = data_home
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(absolute)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share")));
|
||||
let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share");
|
||||
home_dir
|
||||
.into_iter()
|
||||
.chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from))
|
||||
.map(|d| d.join("applications"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// Settings › Default apps, on the page of the app registered as `WordCraft` under
|
||||
/// `HKLM\Software\RegisteredApplications` (the MSI writes it).
|
||||
const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft";
|
||||
|
||||
pub(super) fn open_default_apps() -> Result<DefaultApp, String> {
|
||||
open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?;
|
||||
Ok(DefaultApp::SettingsOpened)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod xdg {
|
||||
use std::process::Command;
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
use super::{DESKTOP_ID, application_dirs, xdg_mime_args};
|
||||
|
||||
/// The longest error text from `xdg-mime` shown in the status bar.
|
||||
const MAX_ERROR: usize = 200;
|
||||
|
||||
pub(super) fn make_default() -> Result<DefaultApp, String> {
|
||||
if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() {
|
||||
return Err(
|
||||
"the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into()
|
||||
);
|
||||
}
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref());
|
||||
if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) {
|
||||
return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)"));
|
||||
}
|
||||
let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(),
|
||||
_ => format!("couldn't run xdg-mime: {e}"),
|
||||
})?;
|
||||
if out.status.success() {
|
||||
return Ok(DefaultApp::MadeDefault);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||
let message: String = first.chars().take(MAX_ERROR).collect();
|
||||
Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_mime_sets_every_word_type_in_one_call() {
|
||||
let args = xdg_mime_args(DESKTOP_ID);
|
||||
assert_eq!(args.first().map(String::as_str), Some("default"));
|
||||
assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop"));
|
||||
assert_eq!(&args[2..], WORD_MIME_TYPES);
|
||||
for t in [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
] {
|
||||
assert!(args.iter().any(|a| a == t), "{t}");
|
||||
}
|
||||
assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed");
|
||||
}
|
||||
|
||||
/// Every type the button claims is one the desktop entry declares, or desktops ignore it.
|
||||
#[test]
|
||||
fn the_desktop_entry_declares_every_claimed_type() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
for t in WORD_MIME_TYPES {
|
||||
assert!(declared.contains(t), "{t} missing from MimeType=");
|
||||
}
|
||||
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);
|
||||
assert_eq!(
|
||||
application_dirs(None, None, Some("/home/a")),
|
||||
[p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
assert_eq!(
|
||||
application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")),
|
||||
[p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
// Empty or relative values fall back to the defaults; no home at all is fine.
|
||||
assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,19 @@
|
||||
//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the
|
||||
//! window, and a background thread waits for the answer, hands it to the app and wakes the UI.
|
||||
//!
|
||||
//! On Linux and the BSDs rfd asks the xdg-desktop-portal FileChooser and, failing that, runs
|
||||
//! `zenity`; with neither (a minimal Wayland compositor such as labwc with only
|
||||
//! xdg-desktop-portal-wlr, #576) it answers "cancelled" and nothing ever shows. So the waiting
|
||||
//! thread first checks that one of them is there and otherwise answers with an error, and the app
|
||||
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
|
||||
//!
|
||||
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
|
||||
use wordcraft_ui_egui::FileDialogRequest;
|
||||
|
||||
/// A dialog the app asked for, with the channel its answer goes back through.
|
||||
type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
type Request = (FileDialogRequest, Sender<Option<String>>);
|
||||
|
||||
/// The `Services::file_dialog` hook.
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
|
||||
@@ -57,18 +51,11 @@ impl Launcher {
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let exts = save_exts(&name);
|
||||
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
|
||||
}
|
||||
FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()),
|
||||
};
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
|
||||
};
|
||||
let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string());
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
@@ -80,102 +67,16 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types Open offers (shared with the built-in browser).
|
||||
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
|
||||
d = d.add_filter(*name, exts);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Why no system file dialog can be shown, if it can't (#576); run off the UI thread.
|
||||
fn no_system_dialog() -> Option<String> {
|
||||
if std::env::var_os("WORDCRAFT_FILE_DIALOG").is_some_and(|v| v == "builtin") {
|
||||
return Some("WORDCRAFT_FILE_DIALOG=builtin".into());
|
||||
}
|
||||
portal_or_zenity_missing()
|
||||
}
|
||||
|
||||
/// Linux and the BSDs: rfd needs a file chooser portal or zenity. When `dbus-send` can't ask
|
||||
/// the session bus, the answer is unknown and rfd gets to try.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn portal_or_zenity_missing() -> Option<String> {
|
||||
if on_path("zenity") {
|
||||
return None;
|
||||
}
|
||||
let portal = std::process::Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--print-reply=literal",
|
||||
"--reply-timeout=10000",
|
||||
"--dest=org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.freedesktop.portal.FileChooser",
|
||||
"string:version",
|
||||
])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match portal {
|
||||
Ok(status) if !status.success() => Some("no xdg-desktop-portal FileChooser on the session bus and no zenity".into()),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't ask D-Bus for the file chooser portal: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows and macOS always have their system dialog.
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn portal_or_zenity_missing() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether an executable named `program` is in a `PATH` folder.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn on_path(program: &str) -> bool {
|
||||
std::env::var_os("PATH").is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
|
||||
}
|
||||
|
||||
/// The file types Save As offers: every format the engine saves, with the suggested name's own
|
||||
/// format first. rfd makes a filter's first extension the dialog's default, which the Windows,
|
||||
/// macOS and Linux panels append to a file name the user typed without one; with no filter at
|
||||
/// all the name came back bare and the save failed, since the format is chosen by extension
|
||||
/// (#689).
|
||||
fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
exts.push(suggested.clone());
|
||||
exts.extend(wordcraft_engine::io::SAVE_EXTS.iter().filter(|e| **e != suggested).map(|e| (*e).to_string()));
|
||||
exts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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
|
||||
/// bare and failing.
|
||||
#[test]
|
||||
fn save_dialog_defaults_to_the_suggested_format() {
|
||||
assert_eq!(save_exts("novel.docx")[0], "docx");
|
||||
assert_eq!(save_exts("report.odt")[0], "odt");
|
||||
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
|
||||
// A suggested name always has an extension; anything unreadable falls back to Word's.
|
||||
assert_eq!(save_exts("no extension")[0], "docx");
|
||||
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
|
||||
// Every savable format is still offered, once.
|
||||
let exts = save_exts("novel.docx");
|
||||
assert!(wordcraft_engine::io::SAVE_EXTS.iter().all(|e| exts.iter().any(|x| x == e)));
|
||||
assert_eq!(exts.len(), wordcraft_engine::io::SAVE_EXTS.len());
|
||||
/// The file types Open offers: pictures for Insert/Change Picture, recipient lists for Select
|
||||
/// Recipients › Use an Existing List…, documents otherwise.
|
||||
fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
if purpose == "picture" {
|
||||
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
|
||||
} else if purpose == "recipients" {
|
||||
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
|
||||
} else {
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"])
|
||||
.add_filter("Word document", &["docx"])
|
||||
.add_filter("All files", &["*"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-131
@@ -9,22 +9,19 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod animations;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
mod default_apps;
|
||||
mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
mod print;
|
||||
mod region;
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
paste_picture::ClipboardPicture,
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
@@ -51,23 +48,11 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// 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() {
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// 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.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.0.quit_requested = false;
|
||||
schedule_macos_terminate(&self.0, self.2.as_ref());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -78,7 +63,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();
|
||||
@@ -86,40 +70,6 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit the way Command-Q does, on the next turn of the main queue.
|
||||
///
|
||||
/// Called when the window is closing. Doing it on the next turn lets the close
|
||||
/// button's display cycle finish while the window still exists; `terminate:`
|
||||
/// then ends the process. Preferences are written and the control key file is
|
||||
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
|
||||
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
|
||||
#[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();
|
||||
}
|
||||
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||
}
|
||||
// `terminate:` does not return when it succeeds. If it did, leave anyway:
|
||||
// staying up after the close was cancelled would trap the user in the window.
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))
|
||||
@@ -130,21 +80,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()
|
||||
@@ -162,19 +97,6 @@ fn load_prefs(app: &mut WordApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts
|
||||
/// added later there. Runs without preferences neither read nor keep them.
|
||||
fn load_user_fonts() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return };
|
||||
let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);
|
||||
if !loaded.is_empty() || !skipped.is_empty() {
|
||||
log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
@@ -199,26 +121,29 @@ fn save_prefs(app: &WordApp) {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
/// through the system's PDF viewer (`print`, #15), and pictures on the system clipboard (#45).
|
||||
fn services(file_dialog: file_dialogs::Hook) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
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)),
|
||||
print: Some(print::hook()),
|
||||
clipboard_picture: Some(Box::new(clipboard_picture)),
|
||||
..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")]
|
||||
@@ -282,25 +207,14 @@ 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();
|
||||
let restored = saved_window();
|
||||
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")]
|
||||
@@ -311,23 +225,10 @@ 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(®ion) };
|
||||
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));
|
||||
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);
|
||||
app.ui.window = restored;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
let mut key_file = None;
|
||||
@@ -337,12 +238,6 @@ 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() && !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}");
|
||||
|
||||
+211
-58
@@ -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| {
|
||||
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(),
|
||||
..Default::default()
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,9 +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.lokinmodar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -41,41 +38,12 @@ real_name = "Dmitrii Khlapov"
|
||||
display_name = "Dima Khlapov"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DanielKnourek]
|
||||
real_name = "Daniel Kňourek"
|
||||
display_name = "Daniel Kňourek"
|
||||
sync_github_name = false
|
||||
|
||||
[people.arcanii]
|
||||
real_name = "Bryan Mark"
|
||||
display_name = "Arcanii"
|
||||
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]
|
||||
[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.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-19
@@ -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();
|
||||
|
||||
+1
-176
@@ -1,6 +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).
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -16,176 +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,
|
||||
];
|
||||
|
||||
/// 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];
|
||||
|
||||
/// One byte of a single-byte Windows code page as a char; a code page outside
|
||||
/// [`SINGLE_BYTE_CODE_PAGES`] 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,
|
||||
_ => 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}')
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250… `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 {
|
||||
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]), "é");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +22,7 @@ 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(skip)]
|
||||
pub source: Option<std::sync::Arc<Embedded>>,
|
||||
@@ -44,8 +44,7 @@ pub struct Graphic {
|
||||
#[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>,
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-95
@@ -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;
|
||||
@@ -139,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
|
||||
@@ -282,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).
|
||||
@@ -470,25 +445,12 @@ pub struct Settings {
|
||||
/// Equation options (`m:mathPr`), when the document has them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub math: Option<math::MathProps>,
|
||||
/// Citation and bibliography style (References › Style) by name, when the document
|
||||
/// records one (OOXML's `b:Sources/@StyleName`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bib_style: Option<String>,
|
||||
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
|
||||
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attached_template: Option<String>,
|
||||
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
|
||||
pub link_styles: bool,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
@@ -563,11 +525,8 @@ impl Default for Settings {
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
math: None,
|
||||
bib_style: None,
|
||||
attached_template: None,
|
||||
link_styles: false,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -814,19 +773,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, false)
|
||||
}
|
||||
|
||||
/// [`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, true)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, shown: bool) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0, shown);
|
||||
text_of(bl, &mut out, 0);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
@@ -904,22 +853,14 @@ impl Document {
|
||||
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
|
||||
@@ -1307,14 +1248,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
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(&if shown { p.shown_text() } else { p.plain_text() });
|
||||
out.push_str(&p.plain_text());
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
@@ -1322,7 +1263,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, shown);
|
||||
text_of(&c.blocks, &mut s, depth + 1);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
@@ -1333,34 +1274,5 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -208,11 +208,6 @@ 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);
|
||||
|
||||
+3
-54
@@ -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.
|
||||
@@ -451,8 +447,8 @@ pub enum InlineObject {
|
||||
code: bool,
|
||||
},
|
||||
/// The start of a field whose result is ordinary content — formatted, possibly spanning
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
|
||||
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN`
|
||||
/// fields (Zotero, Mendeley, EndNote) are kept this way; `instr` is the field code. See
|
||||
/// [`crate::fields`].
|
||||
FieldStart {
|
||||
instr: String,
|
||||
@@ -835,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())
|
||||
@@ -1068,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);
|
||||
@@ -1445,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());
|
||||
|
||||
+20
-144
@@ -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);
|
||||
@@ -684,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")]
|
||||
@@ -744,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.
|
||||
@@ -789,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.
|
||||
@@ -827,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 {
|
||||
@@ -1109,10 +1025,6 @@ pub struct CellProps {
|
||||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
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>>>,
|
||||
@@ -1183,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() };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -174,7 +174,7 @@ impl StyleSheet {
|
||||
/// 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),
|
||||
@@ -444,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> {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -12,8 +12,6 @@ workspace = true
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
image = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
|
||||
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
|
||||
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use crate::media::Pictures;
|
||||
use crate::officeart::{Node, Sp};
|
||||
|
||||
/// Most anchors (and text box stories) read per PLC.
|
||||
const MAX_ANCHORS: usize = 10_000;
|
||||
/// `Spa` and `FTXBXS` record sizes.
|
||||
const CB_SPA: usize = 26;
|
||||
const CB_FTXBXS: usize = 22;
|
||||
/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes).
|
||||
const MAX_PT: f32 = 1584.0;
|
||||
/// Deepest group nesting flattened.
|
||||
const MAX_GROUP_DEPTH: usize = 8;
|
||||
|
||||
/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped.
|
||||
fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
|
||||
let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
let n = blob.len().saturating_sub(4) / (4 + cb);
|
||||
let n = n.min(MAX_ANCHORS);
|
||||
let data_at = (n + 1) * 4;
|
||||
(0..n)
|
||||
.filter_map(|i| {
|
||||
let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?);
|
||||
let at = data_at + i * cb;
|
||||
Some((cp, blob.get(at..at + cb)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
|
||||
/// relative to the text box subdocument).
|
||||
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();
|
||||
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 && lid != 0 {
|
||||
out.push((lid, (*cp, end)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What building the floating objects needs.
|
||||
pub(crate) struct Build<'a> {
|
||||
/// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument).
|
||||
pub(crate) bstore: &'a [&'a [u8]],
|
||||
pub(crate) delay: &'a [u8],
|
||||
/// Text box part ids by shape spid.
|
||||
pub(crate) stories: &'a HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
/// The floating objects anchored by `PlcfSpa` (at `pair`), as (absolute anchor CP, object),
|
||||
/// sorted by CP. `cp_base` turns the PLC's CPs into absolute ones (the header subdocument's
|
||||
/// start for `PlcSpaHdr`).
|
||||
pub(crate) fn anchors(
|
||||
table: &[u8],
|
||||
pair: Option<(u32, u32)>,
|
||||
cp_base: u32,
|
||||
shapes: &HashMap<u32, Node>,
|
||||
b: &Build,
|
||||
pics: &mut Pictures,
|
||||
) -> Vec<(u32, InlineObject)> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (cp, spa) in plc(table, pair, CB_SPA) {
|
||||
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
|
||||
let spid = i32_at(0) as u32;
|
||||
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
|
||||
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let Some(node) = shapes.get(&spid) else { continue };
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
let sp = node.sp();
|
||||
let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
|
||||
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
|
||||
// unrotated frame has the other aspect, about the same centre.
|
||||
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
|
||||
x += (w - h) / 2.0;
|
||||
y += (h - w) / 2.0;
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let mut float = spa_float(flags, &sp.props);
|
||||
float.x = x;
|
||||
float.y = y;
|
||||
float.rot = rot;
|
||||
float.flip_h = sp.flip_h();
|
||||
float.flip_v = sp.flip_v();
|
||||
let obj = match node {
|
||||
Node::Shape(sp) => leaf(sp, w, h, float, b, pics),
|
||||
Node::Group(sp, members) => group(sp, members, w, h, float, b, pics),
|
||||
};
|
||||
match obj {
|
||||
Some(obj) => out.push((cp_base.saturating_add(cp), obj)),
|
||||
None => pics.dropped(cp_base.saturating_add(cp)),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(cp, _)| *cp);
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock)
|
||||
/// and the shape's position properties, which take precedence when present.
|
||||
fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float {
|
||||
let bx = (flags >> 1) & 3;
|
||||
let by = (flags >> 3) & 3;
|
||||
let wr = (flags >> 5) & 0xF;
|
||||
let below = flags & (1 << 14) != 0;
|
||||
let wrap = match wr {
|
||||
1 => Wrap::TopAndBottom,
|
||||
3 => {
|
||||
if below {
|
||||
Wrap::BehindText
|
||||
} else {
|
||||
Wrap::InFrontOfText
|
||||
}
|
||||
}
|
||||
4 => Wrap::Tight,
|
||||
5 => Wrap::Through,
|
||||
_ => Wrap::Square,
|
||||
};
|
||||
let h_rel = match p.posrelh.unwrap_or(bx as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Character,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel = match p.posrelv.unwrap_or(by as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Line,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let align = |v: Option<u32>| match v {
|
||||
Some(1) => Some(FloatAlign::Start),
|
||||
Some(2) => Some(FloatAlign::Center),
|
||||
Some(3) => Some(FloatAlign::End),
|
||||
Some(4) => Some(FloatAlign::Inside),
|
||||
Some(5) => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
};
|
||||
// Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below.
|
||||
let emu = |v: Option<u32>, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT));
|
||||
let [dl, dt, dr, db] = p.wrap_dist;
|
||||
Float {
|
||||
wrap,
|
||||
h_rel,
|
||||
v_rel,
|
||||
h_align: align(p.posh),
|
||||
v_align: align(p.posv),
|
||||
dist: emu(dl, 9.0).max(emu(dr, 9.0)),
|
||||
dist_top: emu(dt, 0.0),
|
||||
dist_bottom: emu(db, 0.0),
|
||||
..Float::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One shape as a picture or a drawn shape, `w` × `h` points.
|
||||
fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let p = &sp.props;
|
||||
if let Some(pib) = p.pib {
|
||||
let media = pics.bse(pib, b.bstore, b.delay)?;
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
|
||||
}
|
||||
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
|
||||
let mut kind = match sp.spt {
|
||||
1 => ShapeKind::Rectangle,
|
||||
2 => ShapeKind::RoundedRectangle,
|
||||
3 => ShapeKind::Ellipse,
|
||||
4 => ShapeKind::Diamond,
|
||||
5 | 6 => ShapeKind::Triangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
74 => ShapeKind::Heart,
|
||||
202 => ShapeKind::TextBox,
|
||||
// A freeform's path is not read: better left out than drawn as a box.
|
||||
0 => return None,
|
||||
_ => ShapeKind::Rectangle,
|
||||
};
|
||||
if story.is_some() && kind == ShapeKind::Rectangle {
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
Some(InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A group: its members flattened into the group's own coordinate space (nested groups mapped
|
||||
/// through their child anchors).
|
||||
fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]);
|
||||
let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) };
|
||||
let mut children = Vec::new();
|
||||
// Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin.
|
||||
members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec<GroupChild>) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 };
|
||||
for n in nodes {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
let sp = n.sp();
|
||||
let Some([l, t, r, btm]) = sp.child else { continue };
|
||||
let (ox, oy) = (l as f32, t as f32);
|
||||
let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
match n {
|
||||
Node::Group(_, members) => {
|
||||
if depth >= MAX_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]);
|
||||
let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32);
|
||||
let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) };
|
||||
let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky));
|
||||
members_into(members, nt, depth + 1, b, pics, out);
|
||||
}
|
||||
Node::Shape(sp) => {
|
||||
let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() };
|
||||
let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0));
|
||||
if let Some(obj) = leaf(sp, w, h, float, b, pics) {
|
||||
out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,8 @@ pub(crate) mod pair {
|
||||
pub(crate) const PLCF_BKF: usize = 22;
|
||||
pub(crate) const PLCF_BKL: usize = 23;
|
||||
pub(crate) const CLX: usize = 33;
|
||||
/// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers.
|
||||
pub(crate) const PLC_SPA_MOM: usize = 40;
|
||||
pub(crate) const PLC_SPA_HDR: usize = 41;
|
||||
pub(crate) const PLCF_END_REF: usize = 46;
|
||||
pub(crate) const PLCF_END_TXT: usize = 47;
|
||||
/// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes).
|
||||
pub(crate) const DGG_INFO: usize = 50;
|
||||
/// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers.
|
||||
pub(crate) const PLCF_TXBX_TXT: usize = 56;
|
||||
pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58;
|
||||
/// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob).
|
||||
pub(crate) const PLF_LST: usize = 73;
|
||||
/// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 drawing;
|
||||
mod fib;
|
||||
mod fkp;
|
||||
mod fmt;
|
||||
mod list;
|
||||
mod media;
|
||||
mod notes;
|
||||
mod officeart;
|
||||
mod piece;
|
||||
mod sections;
|
||||
mod sprm;
|
||||
@@ -141,36 +139,8 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
sheet: &sheet,
|
||||
notes: &[],
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
// anchored in the main document and the headers, found by their anchor CP while walking.
|
||||
let txbx_base = edn_base.saturating_add(fib.ccp.edn);
|
||||
let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx);
|
||||
let art = fib
|
||||
.pair(fib::pair::DGG_INFO)
|
||||
.and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize)))
|
||||
.map(officeart::content)
|
||||
.unwrap_or_default();
|
||||
let mut tb_stories = std::collections::HashMap::new();
|
||||
for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] {
|
||||
for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) {
|
||||
let Some((a, b)) = story(base, len, ab) else { continue };
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories };
|
||||
let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics);
|
||||
drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics));
|
||||
drawn.sort_by_key(|(cp, _)| *cp);
|
||||
let ctx = WalkCtx { drawn: &drawn, ..ctx };
|
||||
|
||||
// Header and footer parts: skip the six separator stories, then six stories per section
|
||||
// in the order even header, odd (default) header, even footer, odd (default) footer,
|
||||
// first header, first footer. Empty stories inherit from the previous section.
|
||||
@@ -216,9 +186,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
// 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: ¬e_links, marks: &marks, sect_ends: §_ends, ..ctx };
|
||||
let ctx = WalkCtx { notes: ¬e_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.
|
||||
@@ -278,11 +246,6 @@ struct WalkCtx<'a> {
|
||||
notes: &'a [(u32, NoteKind, u32)],
|
||||
/// Bookmark boundaries (main-document CPs), sorted for a sequential walk.
|
||||
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],
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -356,10 +319,8 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 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);
|
||||
@@ -444,29 +405,21 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some(p) => {
|
||||
Some((key, w, h)) => {
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: p.key,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt: p.alt,
|
||||
media: key,
|
||||
w,
|
||||
h,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
});
|
||||
}
|
||||
None => pics.dropped(cp),
|
||||
}
|
||||
}
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
}
|
||||
'\u{2}' => {
|
||||
// Footnote or endnote reference; custom symbols share the same character.
|
||||
let at = ctx.notes.partition_point(|(c, _, _)| *c < cp);
|
||||
|
||||
+20
-119
@@ -1,13 +1,10 @@
|
||||
//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes
|
||||
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the
|
||||
//! document reads (inline and floating).
|
||||
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::officeart;
|
||||
|
||||
/// `PICF.cbHeader` — the fixed size of the PICF structure.
|
||||
const CB_HEADER: usize = 0x44;
|
||||
/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28.
|
||||
@@ -20,13 +17,10 @@ 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, 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.
|
||||
/// thousands of anchors on one huge picture costs one copy, not thousands.
|
||||
pub(crate) struct Pictures {
|
||||
/// 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>>,
|
||||
/// fc_pic → (media key, width, height) of pictures already materialised.
|
||||
by_fc: HashMap<u32, (String, f32, f32)>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
@@ -34,89 +28,33 @@ pub(crate) struct Pictures {
|
||||
pub(crate) limit: usize,
|
||||
/// Anchors whose picture was dropped (only the first few are logged).
|
||||
dropped: usize,
|
||||
/// Pictures kept as placeholders (formats we can't draw).
|
||||
placeholders: usize,
|
||||
}
|
||||
|
||||
impl Default for Pictures {
|
||||
fn default() -> Self {
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
Pictures { by_fc: HashMap::new(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, dropped: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// An inline picture: its media key, display size (points), crop fractions and alt text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Inline {
|
||||
pub(crate) key: String,
|
||||
pub(crate) w: f32,
|
||||
pub(crate) h: f32,
|
||||
pub(crate) crop: [f32; 4],
|
||||
pub(crate) alt: String,
|
||||
}
|
||||
|
||||
impl Pictures {
|
||||
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
|
||||
/// `None` when the header is malformed, the picture can't be read, or a picture
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
/// The media key and display size (points) of the picture at `fc_pic`, copying its
|
||||
/// bytes the first time. `None` when the header is malformed, the payload is a format
|
||||
/// we do not embed (metafiles), or a picture count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<(String, f32, f32)> {
|
||||
if let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
if self.by_fc.len() >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = match locate(data, fc_pic)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone())?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
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)?;
|
||||
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`
|
||||
/// is the stream that holds BLIPs not embedded in the store (the WordDocument stream).
|
||||
pub(crate) fn bse(&mut self, pib: u32, bstore: &[&[u8]], delay: &[u8]) -> Option<String> {
|
||||
if let Some(hit) = self.by_bse.get(&pib) {
|
||||
return hit.clone();
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Add picture bytes as a new media entry, within the byte cap.
|
||||
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
let pic = locate(data, fc_pic)?;
|
||||
let bytes = data.get(pic.range)?;
|
||||
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
|
||||
let key = format!("image{}.{ext}", self.media.len() + 1);
|
||||
if placeholder {
|
||||
if self.placeholders < 10 {
|
||||
log::warn!("docbin: picture `{key}` is in a format WordCraft can't draw ({ext}); it shows as a placeholder");
|
||||
}
|
||||
self.placeholders = self.placeholders.saturating_add(1);
|
||||
}
|
||||
self.media.insert(key.clone(), Arc::new(bytes));
|
||||
let key = format!("image{}.{}", self.by_fc.len() + 1, pic.ext);
|
||||
self.media.insert(key.clone(), Arc::new(bytes.to_vec()));
|
||||
self.total = total;
|
||||
Some(key)
|
||||
self.by_fc.insert(fc_pic, (key.clone(), pic.w, pic.h));
|
||||
Some((key, pic.w, pic.h))
|
||||
}
|
||||
|
||||
/// Note a picture anchor that could not be materialised.
|
||||
@@ -136,64 +74,27 @@ struct Located {
|
||||
ext: &'static str,
|
||||
}
|
||||
|
||||
/// What a `PICFAndOfficeArtData` holds: raw image bytes (older writers), or an
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
/// name (`MM_SHAPEFILE`).
|
||||
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).
|
||||
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
|
||||
/// Locate the picture at `fc_pic` in the Data stream without copying it.
|
||||
fn locate(data: &[u8], fc_pic: u32) -> Option<Located> {
|
||||
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;
|
||||
let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize;
|
||||
let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER };
|
||||
let mm = u16::from_le_bytes(pic.get(6..8)?.try_into().ok()?);
|
||||
let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?;
|
||||
let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32;
|
||||
let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32;
|
||||
let mx = u16::from_le_bytes([m[4], m[5]]) as f32;
|
||||
let my = u16::from_le_bytes([m[6], m[7]]) as f32;
|
||||
let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 };
|
||||
let end = lcb.min(pic.len());
|
||||
if mm == MM_SHAPE || mm == MM_SHAPEFILE {
|
||||
let mut at = cb_header;
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
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);
|
||||
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
|
||||
return Some(Found::Shape { w, h, blip, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
let end = lcb.min(pic.len());
|
||||
let body = pic.get(cb_header..end)?;
|
||||
let at = sniff(body)?;
|
||||
let image = body.get(at..)?;
|
||||
let start = base.checked_add(cb_header)?.checked_add(at)?;
|
||||
Some(Found::Range(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) }))
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `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, MAX_MEDIA_BYTES)?;
|
||||
Some((blip, sp.props))
|
||||
Some(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) })
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
|
||||
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
|
||||
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
|
||||
//!
|
||||
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
|
||||
//! and record counts are capped, and decompressed metafiles are size-limited.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
/// Deepest container nesting walked (groups inside groups…).
|
||||
const MAX_DEPTH: usize = 12;
|
||||
/// Most records walked in one drawing tree or BLIP store.
|
||||
const MAX_RECORDS: usize = 200_000;
|
||||
/// Most BLIP store entries kept.
|
||||
const MAX_BSE: usize = 10_000;
|
||||
/// Most drawings (main document, headers) read from `OfficeArtContent`.
|
||||
const MAX_DRAWINGS: usize = 8;
|
||||
/// Largest decompressed metafile (bytes).
|
||||
const MAX_METAFILE: usize = 64 << 20;
|
||||
/// Largest DIB converted to PNG, per side (pixels).
|
||||
const MAX_DIB_SIDE: u32 = 16_384;
|
||||
|
||||
/// Record types ([MS-ODRAW] §2.2, §2.3).
|
||||
pub(crate) mod rt {
|
||||
pub(crate) const DGG_CONTAINER: u16 = 0xF000;
|
||||
pub(crate) const BSTORE_CONTAINER: u16 = 0xF001;
|
||||
pub(crate) const DG_CONTAINER: u16 = 0xF002;
|
||||
pub(crate) const SPGR_CONTAINER: u16 = 0xF003;
|
||||
pub(crate) const SP_CONTAINER: u16 = 0xF004;
|
||||
pub(crate) const FBSE: u16 = 0xF007;
|
||||
pub(crate) const FSPGR: u16 = 0xF009;
|
||||
pub(crate) const FSP: u16 = 0xF00A;
|
||||
pub(crate) const FOPT: u16 = 0xF00B;
|
||||
pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D;
|
||||
pub(crate) const CHILD_ANCHOR: u16 = 0xF00F;
|
||||
pub(crate) const SECONDARY_FOPT: u16 = 0xF121;
|
||||
pub(crate) const TERTIARY_FOPT: u16 = 0xF122;
|
||||
pub(crate) const BLIP_EMF: u16 = 0xF01A;
|
||||
pub(crate) const BLIP_WMF: u16 = 0xF01B;
|
||||
pub(crate) const BLIP_PICT: u16 = 0xF01C;
|
||||
pub(crate) const BLIP_JPEG: u16 = 0xF01D;
|
||||
pub(crate) const BLIP_PNG: u16 = 0xF01E;
|
||||
pub(crate) const BLIP_DIB: u16 = 0xF01F;
|
||||
pub(crate) const BLIP_TIFF: u16 = 0xF029;
|
||||
pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A;
|
||||
}
|
||||
|
||||
/// One record: header fields and its body (clamped to what the parent holds).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Rec<'a> {
|
||||
pub(crate) inst: u16,
|
||||
pub(crate) typ: u16,
|
||||
pub(crate) body: &'a [u8],
|
||||
}
|
||||
|
||||
/// The record at the start of `buf` and the bytes it spans (header included). A body longer
|
||||
/// than `buf` is cut to what is there.
|
||||
pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> {
|
||||
let h = buf.get(0..8)?;
|
||||
let ver_inst = u16::from_le_bytes([h[0], h[1]]);
|
||||
let typ = u16::from_le_bytes([h[2], h[3]]);
|
||||
let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let rest = buf.get(8..)?;
|
||||
let len = len.min(rest.len());
|
||||
let body = rest.get(..len)?;
|
||||
Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len))
|
||||
}
|
||||
|
||||
/// The sibling records of a container body, at most `budget` of them (the budget is shared
|
||||
/// across a whole walk, so a deep or wide tree cannot multiply the work).
|
||||
pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec<Rec<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0usize;
|
||||
while *budget > 0 {
|
||||
let Some((rec, used)) = body.get(at..).and_then(record) else { break };
|
||||
*budget -= 1;
|
||||
out.push(rec);
|
||||
at = at.saturating_add(used);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn i32_at(b: &[u8], at: usize) -> Option<i32> {
|
||||
u32_at(b, at).map(|v| v as i32)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pictures (BLIPs).
|
||||
|
||||
/// A decoded picture: bytes ready for `Document::media` and their file extension.
|
||||
pub(crate) struct Blip {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) ext: &'static str,
|
||||
/// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of
|
||||
/// the right size.
|
||||
pub(crate) placeholder: bool,
|
||||
}
|
||||
|
||||
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
|
||||
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
|
||||
/// inline pictures). `limit` caps the bytes produced.
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
|
||||
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
|
||||
let size = u32_at(fbse, 20)? as usize;
|
||||
let fo_delay = u32_at(fbse, 28)? as usize;
|
||||
let cb_name = *fbse.get(33)? as usize;
|
||||
let at = 36usize.checked_add(cb_name)?;
|
||||
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
|
||||
return blip(&rec, limit);
|
||||
}
|
||||
// Not embedded: the record sits in the delay stream.
|
||||
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
|
||||
let (rec, _) = record(delay.get(fo_delay..end)?)?;
|
||||
blip(&rec, limit)
|
||||
}
|
||||
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
// Each type has two instance values; the odd one carries a second 16-byte UID.
|
||||
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
|
||||
match rec.typ {
|
||||
rt::BLIP_PNG | rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK | rt::BLIP_DIB | rt::BLIP_TIFF => {
|
||||
let data = rec.body.get(uids + 1..)?; // + tag byte
|
||||
if data.is_empty() || data.len() > limit {
|
||||
return None;
|
||||
}
|
||||
match rec.typ {
|
||||
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),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
// OfficeArtMetafileHeader: cbSize, rcBounds, ptSize, cbSave, compression, filter.
|
||||
let hdr = rec.body.get(uids..uids + 34)?;
|
||||
let cb_size = u32_at(hdr, 0)? as usize;
|
||||
let cb_save = u32_at(hdr, 28)? as usize;
|
||||
let compression = *hdr.get(32)?;
|
||||
let raw = rec.body.get(uids + 34..)?;
|
||||
let raw = raw.get(..cb_save.min(raw.len()))?;
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
return None;
|
||||
}
|
||||
raw.to_vec()
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match rec.typ {
|
||||
rt::BLIP_EMF => Some(Blip { bytes, ext: "emf", placeholder: false }),
|
||||
rt::BLIP_WMF => Some(Blip { bytes, ext: "wmf", placeholder: false }),
|
||||
_ => Some(Blip { bytes, ext: "pict", placeholder: true }),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
}
|
||||
let (bits, used, compression) =
|
||||
if header == 12 { (u16_at(dib, 10)? as usize, 0, 0) } else { (u16_at(dib, 14)? as usize, u32_at(dib, 32)? as usize, u32_at(dib, 16)?) };
|
||||
let entry = if header == 12 { 3 } else { 4 };
|
||||
let colors = if used > 0 {
|
||||
used.min(256)
|
||||
} else if bits <= 8 {
|
||||
1usize << bits
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// 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())?;
|
||||
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());
|
||||
bmp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
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);
|
||||
reader.limits(limits);
|
||||
let mut png = Vec::new();
|
||||
match reader.decode() {
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shapes.
|
||||
|
||||
/// The shape properties we use, from `OfficeArtFOPT` and its secondary/tertiary tables
|
||||
/// ([MS-ODRAW] §2.3).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Props {
|
||||
/// `pib`: 1-based index of the picture in the BLIP store.
|
||||
pub(crate) pib: Option<u32>,
|
||||
/// Rotation, degrees clockwise.
|
||||
pub(crate) rot: f32,
|
||||
/// Crop fractions (left, top, right, bottom).
|
||||
pub(crate) crop: [f32; 4],
|
||||
pub(crate) fill_color: Option<Rgb>,
|
||||
pub(crate) filled: Option<bool>,
|
||||
pub(crate) line_color: Option<Rgb>,
|
||||
/// Line width in EMUs.
|
||||
pub(crate) line_width: Option<u32>,
|
||||
pub(crate) line: Option<bool>,
|
||||
/// An arrowhead at either end of a line.
|
||||
pub(crate) arrowhead: bool,
|
||||
/// `posh`/`posrelh`/`posv`/`posrelv`: alignment and reference frame (Word 2000 and later).
|
||||
pub(crate) posh: Option<u32>,
|
||||
pub(crate) posrelh: Option<u32>,
|
||||
pub(crate) posv: Option<u32>,
|
||||
pub(crate) posrelv: Option<u32>,
|
||||
/// Distance from wrapped text (left, top, right, bottom), EMUs.
|
||||
pub(crate) wrap_dist: [Option<u32>; 4],
|
||||
/// `wzDescription`: the alternative text.
|
||||
pub(crate) alt: String,
|
||||
}
|
||||
|
||||
impl Props {
|
||||
/// Apply one property table (`OfficeArtFOPT` or a secondary/tertiary one): `count` 6-byte
|
||||
/// entries, then the complex data of the entries flagged complex, in order.
|
||||
pub(crate) fn apply(&mut self, body: &[u8], count: u16) {
|
||||
let n = (count as usize).min(body.len() / 6);
|
||||
let mut complex_at = n * 6;
|
||||
for i in 0..n {
|
||||
let Some(opid) = u16_at(body, i * 6) else { break };
|
||||
let Some(op) = u32_at(body, i * 6 + 2) else { break };
|
||||
let pid = opid & 0x3FFF;
|
||||
if opid & 0x8000 != 0 {
|
||||
let start = complex_at;
|
||||
complex_at = complex_at.saturating_add(op as usize);
|
||||
if pid == 0x0381 {
|
||||
let end = complex_at.min(body.len());
|
||||
if let Some(data) = body.get(start..end) {
|
||||
let units: Vec<u16> =
|
||||
data.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).take(4096).collect();
|
||||
self.alt = String::from_utf16_lossy(&units);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let fixed = |v: u32| (v as i32) as f32 / 65_536.0;
|
||||
match pid {
|
||||
0x0004 => self.rot = fixed(op),
|
||||
0x0100..=0x0103 => {
|
||||
// cropFromTop, cropFromBottom, cropFromLeft, cropFromRight.
|
||||
let slot = [1, 3, 0, 2][(pid - 0x0100) as usize];
|
||||
let v = fixed(op);
|
||||
self.crop[slot] = if v.is_finite() { v.clamp(0.0, 0.99) } else { 0.0 };
|
||||
}
|
||||
0x0104 => self.pib = Some(op),
|
||||
0x0181 => self.fill_color = color(op),
|
||||
0x01BF => {
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
if op & (1 << 20) != 0 {
|
||||
self.filled = Some(op & (1 << 4) != 0);
|
||||
}
|
||||
}
|
||||
0x01C0 => self.line_color = color(op),
|
||||
0x01CB => self.line_width = Some(op),
|
||||
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
|
||||
0x01FF => {
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
if op & (1 << 19) != 0 {
|
||||
self.line = Some(op & (1 << 3) != 0);
|
||||
}
|
||||
}
|
||||
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
|
||||
0x038F => self.posh = Some(op),
|
||||
0x0390 => self.posrelh = Some(op),
|
||||
0x0391 => self.posv = Some(op),
|
||||
0x0392 => self.posrelv = Some(op),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `OfficeArtCOLORREF` holding a literal RGB value (palette, scheme and system colour
|
||||
/// references are not resolved).
|
||||
fn color(v: u32) -> Option<Rgb> {
|
||||
let [r, g, b, flags] = v.to_le_bytes();
|
||||
// fPaletteIndex, fSchemeIndex, fSysIndex.
|
||||
if flags & 0x19 != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Rgb(r, g, b))
|
||||
}
|
||||
|
||||
/// One shape (`OfficeArtSpContainer`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Sp {
|
||||
pub(crate) spid: u32,
|
||||
/// Shape type (`MSOSPT`), from the FSP's instance.
|
||||
pub(crate) spt: u16,
|
||||
/// FSP flags: fGroup, fChild, fPatriarch, fDeleted, fOleShape, fHaveMaster, fFlipH, fFlipV…
|
||||
pub(crate) flags: u32,
|
||||
pub(crate) props: Props,
|
||||
/// `OfficeArtChildAnchor` (left, top, right, bottom) in the parent group's space.
|
||||
pub(crate) child: Option<[i32; 4]>,
|
||||
/// `OfficeArtFSPGR`: a group's own coordinate space.
|
||||
pub(crate) group_space: Option<[i32; 4]>,
|
||||
/// Has an `OfficeArtClientTextbox` (its text is in the text box story).
|
||||
pub(crate) textbox: bool,
|
||||
}
|
||||
|
||||
impl Sp {
|
||||
pub(crate) fn flip_h(&self) -> bool {
|
||||
self.flags & (1 << 6) != 0
|
||||
}
|
||||
pub(crate) fn flip_v(&self) -> bool {
|
||||
self.flags & (1 << 7) != 0
|
||||
}
|
||||
fn deleted(&self) -> bool {
|
||||
self.flags & (1 << 3) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape or a group of shapes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Node {
|
||||
Shape(Sp),
|
||||
/// A group: its own shape record (FSPGR, anchor) and members.
|
||||
Group(Sp, Vec<Node>),
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub(crate) fn sp(&self) -> &Sp {
|
||||
match self {
|
||||
Node::Shape(sp) | Node::Group(sp, _) => sp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one `OfficeArtSpContainer`.
|
||||
pub(crate) fn shape(body: &[u8], budget: &mut usize) -> Sp {
|
||||
let mut sp = Sp::default();
|
||||
for rec in children(body, budget) {
|
||||
match rec.typ {
|
||||
rt::FSP => {
|
||||
sp.spt = rec.inst;
|
||||
sp.spid = u32_at(rec.body, 0).unwrap_or(0);
|
||||
sp.flags = u32_at(rec.body, 4).unwrap_or(0);
|
||||
}
|
||||
rt::FOPT | rt::SECONDARY_FOPT | rt::TERTIARY_FOPT => sp.props.apply(rec.body, rec.inst),
|
||||
rt::FSPGR | rt::CHILD_ANCHOR => {
|
||||
let r = [0, 4, 8, 12].map(|o| i32_at(rec.body, o));
|
||||
if let [Some(l), Some(t), Some(r), Some(b)] = r {
|
||||
if rec.typ == rt::FSPGR {
|
||||
sp.group_space = Some([l, t, r, b]);
|
||||
} else {
|
||||
sp.child = Some([l, t, r, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rt::CLIENT_TEXTBOX => sp.textbox = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
/// Parse an `OfficeArtSpgrContainer`: the first shape is the group itself, the rest its members.
|
||||
fn group(body: &[u8], depth: usize, budget: &mut usize) -> Option<Node> {
|
||||
let recs = children(body, budget);
|
||||
let mut it = recs.iter();
|
||||
let head = it.find(|r| r.typ == rt::SP_CONTAINER).map(|r| shape(r.body, budget))?;
|
||||
let mut members = Vec::new();
|
||||
for rec in it {
|
||||
if let Some(n) = node(rec, depth + 1, budget) {
|
||||
members.push(n);
|
||||
}
|
||||
}
|
||||
Some(Node::Group(head, members))
|
||||
}
|
||||
|
||||
/// A shape or group record as a node (`None` for other records, deleted shapes or past the
|
||||
/// depth cap).
|
||||
fn node(rec: &Rec, depth: usize, budget: &mut usize) -> Option<Node> {
|
||||
match rec.typ {
|
||||
rt::SP_CONTAINER => Some(shape(rec.body, budget)).filter(|s| !s.deleted()).map(Node::Shape),
|
||||
rt::SPGR_CONTAINER if depth < MAX_DEPTH => group(rec.body, depth, budget),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's drawings: the BLIP store and the top-level shapes of each drawing by spid.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Content<'a> {
|
||||
/// `OfficeArtFBSE` bodies, in BLIP store order (`pib` 1 = the first).
|
||||
pub(crate) bstore: Vec<&'a [u8]>,
|
||||
/// The main document's shapes (`dgglbl` 0) and the headers' (`dgglbl` 1), by spid.
|
||||
pub(crate) main: HashMap<u32, Node>,
|
||||
pub(crate) header: HashMap<u32, Node>,
|
||||
}
|
||||
|
||||
/// Parse `OfficeArtContent` (the `fcDggInfo` blob of the Table stream): an
|
||||
/// `OfficeArtDggContainer` followed by (`dgglbl`, `OfficeArtDgContainer`) drawings.
|
||||
pub(crate) fn content(blob: &[u8]) -> Content<'_> {
|
||||
let mut out = Content::default();
|
||||
let mut budget = MAX_RECORDS;
|
||||
let Some((dgg, mut at)) = record(blob) else { return out };
|
||||
if dgg.typ == rt::DGG_CONTAINER {
|
||||
for rec in children(dgg.body, &mut budget) {
|
||||
if rec.typ == rt::BSTORE_CONTAINER {
|
||||
out.bstore = children(rec.body, &mut budget).into_iter().filter(|r| r.typ == rt::FBSE).map(|r| r.body).take(MAX_BSE).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
for _ in 0..MAX_DRAWINGS {
|
||||
let Some(&label) = blob.get(at) else { break };
|
||||
let Some((dg, used)) = blob.get(at + 1..).and_then(record) else { break };
|
||||
at = at.saturating_add(1 + used);
|
||||
if dg.typ != rt::DG_CONTAINER {
|
||||
continue;
|
||||
}
|
||||
let into = if label == 0 { &mut out.main } else { &mut out.header };
|
||||
for rec in children(dg.body, &mut budget) {
|
||||
if rec.typ != rt::SPGR_CONTAINER {
|
||||
continue;
|
||||
}
|
||||
// The top group: its first shape is the patriarch; the members are the drawing's shapes.
|
||||
if let Some(Node::Group(_, members)) = group(rec.body, 0, &mut budget) {
|
||||
for n in members {
|
||||
into.entry(n.sp().spid).or_insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+1
-106
@@ -7,8 +7,6 @@ use proptest::prelude::*;
|
||||
|
||||
use super::read;
|
||||
|
||||
mod drawing;
|
||||
|
||||
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
|
||||
const TEXT_FC: u32 = 0x600;
|
||||
/// Offset in the Table stream where the test Clx is placed.
|
||||
@@ -1630,7 +1628,7 @@ fn picture_bytes_are_capped_per_document() {
|
||||
pics.limit = 1000;
|
||||
let first = pics.get(&data, 0).expect("first fits");
|
||||
// Same location again: no new bytes.
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.0), Some(first.0.clone()));
|
||||
// A second distinct picture would exceed the cap.
|
||||
assert!(pics.get(&data, a.len() as u32).is_none());
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
@@ -1803,106 +1801,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"]);
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the
|
||||
//! specification, with tiny pictures generated in code. No file produced by Word is used.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec};
|
||||
use crate::officeart;
|
||||
|
||||
/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body.
|
||||
fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec<u8> {
|
||||
let vi = (inst << 4) | (ver & 0xF);
|
||||
[vi.to_le_bytes().as_slice(), &typ.to_le_bytes(), &(body.len() as u32).to_le_bytes(), body].concat()
|
||||
}
|
||||
|
||||
fn container(typ: u16, kids: &[Vec<u8>]) -> Vec<u8> {
|
||||
rec(0xF, 0, typ, &kids.concat())
|
||||
}
|
||||
|
||||
/// `OfficeArtFSP`: shape type, spid, flags.
|
||||
fn fsp(spt: u16, spid: u32, flags: u32) -> Vec<u8> {
|
||||
rec(2, spt, 0xF00A, &[spid.to_le_bytes(), flags.to_le_bytes()].concat())
|
||||
}
|
||||
|
||||
/// `OfficeArtFOPT` with simple properties and complex ones (data appended in order).
|
||||
fn fopt(simple: &[(u16, u32)], complex: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (pid, op) in simple {
|
||||
body.extend_from_slice(&pid.to_le_bytes());
|
||||
body.extend_from_slice(&op.to_le_bytes());
|
||||
}
|
||||
for (pid, data) in complex {
|
||||
body.extend_from_slice(&(pid | 0x8000).to_le_bytes());
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
}
|
||||
for (_, data) in complex {
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
rec(3, (simple.len() + complex.len()) as u16, 0xF00B, &body)
|
||||
}
|
||||
|
||||
/// A shape container with the given records.
|
||||
fn sp(kids: &[Vec<u8>]) -> Vec<u8> {
|
||||
container(0xF004, kids)
|
||||
}
|
||||
|
||||
/// A real 2×2 PNG.
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// `OfficeArtBlipPNG` (one UID, tag) and the `OfficeArtFBSE` embedding it.
|
||||
fn fbse_png(png: &[u8]) -> Vec<u8> {
|
||||
let blip = rec(0, 0x6E0, 0xF01E, &[[0u8; 16].as_slice(), &[0xFF], png].concat());
|
||||
let mut body = vec![6u8, 6];
|
||||
body.extend_from_slice(&[0u8; 16]); // rgbUid
|
||||
body.extend_from_slice(&0u16.to_le_bytes()); // tag
|
||||
body.extend_from_slice(&(blip.len() as u32).to_le_bytes()); // size
|
||||
body.extend_from_slice(&1u32.to_le_bytes()); // cRef
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // foDelay
|
||||
body.extend_from_slice(&[0, 0, 0, 0]); // unused1, cbName, unused2, unused3
|
||||
body.extend_from_slice(&blip);
|
||||
rec(2, 6, 0xF007, &body)
|
||||
}
|
||||
|
||||
/// UTF-16LE, NUL-terminated (a complex string property).
|
||||
fn wz(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
|
||||
fn spa(spid: u32, [l, t, r, b]: [i32; 4], flags: u16) -> Vec<u8> {
|
||||
[spid.to_le_bytes().as_slice(), &l.to_le_bytes(), &t.to_le_bytes(), &r.to_le_bytes(), &b.to_le_bytes(), &flags.to_le_bytes(), &0u32.to_le_bytes()]
|
||||
.concat()
|
||||
}
|
||||
|
||||
/// `Spa` flags from bx, by, wr and fBelowText.
|
||||
fn spa_flags(bx: u16, by: u16, wr: u16, below: bool) -> u16 {
|
||||
(bx << 1) | (by << 3) | (wr << 5) | (u16::from(below) << 14)
|
||||
}
|
||||
|
||||
/// `OfficeArtContent`: a DggContainer holding a BLIP store, then one main-document drawing.
|
||||
fn art_content(fbses: &[Vec<u8>], shapes: &[Vec<u8>]) -> Vec<u8> {
|
||||
let bstore = rec(0xF, fbses.len() as u16, 0xF001, &fbses.concat());
|
||||
let dgg = container(0xF000, &[bstore]);
|
||||
let patriarch = sp(&[fsp(0, 1024, 0b101)]);
|
||||
let spgr = container(0xF003, &[[patriarch].as_slice(), shapes].concat());
|
||||
let dg = container(0xF002, &[rec(0, 1, 0xF008, &[0u8; 8]), spgr]);
|
||||
[dgg, vec![0u8], dg].concat()
|
||||
}
|
||||
|
||||
/// A PICF for an inline OfficeArt picture: `MM_SHAPE`, 1 × 0.5 inch goal size at 100 %.
|
||||
fn picf_shape(rest: &[u8]) -> Vec<u8> {
|
||||
let mut picf = vec![0u8; 0x44];
|
||||
picf[0..4].copy_from_slice(&((0x44 + rest.len()) as u32).to_le_bytes());
|
||||
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
|
||||
picf[6..8].copy_from_slice(&0x0064u16.to_le_bytes()); // mfpf.mm = MM_SHAPE
|
||||
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
|
||||
picf[30..32].copy_from_slice(&720i16.to_le_bytes());
|
||||
picf[32..34].copy_from_slice(&1000u16.to_le_bytes());
|
||||
picf[34..36].copy_from_slice(&1000u16.to_le_bytes());
|
||||
[picf.as_slice(), rest].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_officeart_picture_with_crop_and_alt_text() {
|
||||
let png = tiny_png();
|
||||
// A picture frame whose pib names the first FBSE that follows it, a quarter cropped
|
||||
// off the left, with alt text.
|
||||
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0102, 0x4000), (0x0104, 1)], &[(0x0381, wz("Logo"))])]);
|
||||
let data = picf_shape(&[shape, fbse_png(&png)].concat());
|
||||
let text = "Pic\u{1}!\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..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!(p0.text, "Pic\u{FFFC}!");
|
||||
let Some(InlineObject::Image { media, w, h, alt, crop, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) };
|
||||
assert_eq!((*w, *h), (54.0, 36.0)); // 72 pt less a quarter, × 36 pt
|
||||
assert_eq!(*crop, [0.25, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(alt, "Logo");
|
||||
assert_eq!(float.wrap, Wrap::Inline);
|
||||
assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_picture_from_the_blip_store_keeps_position_and_wrap() {
|
||||
let png = tiny_png();
|
||||
let pic = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[])]);
|
||||
let art = art_content(&[fbse_png(&png)], &[pic]);
|
||||
// Two inches by one, 1" from the page's left edge and 0.5" below the top margin; square wrap.
|
||||
let spas = plc(&[1, 3], &spa(1025, [1440, 720, 4320, 2160], spa_flags(1, 0, 2, false)));
|
||||
let text = "A\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..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!(p0.text, "A\u{FFFC}B");
|
||||
let Some(InlineObject::Image { media, w, h, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) };
|
||||
assert_eq!((*w, *h), (144.0, 72.0));
|
||||
assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::Square, Anchor::Page, Anchor::Margin, 72.0, 36.0));
|
||||
assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
// A red ellipse with a 2 pt blue outline behind the text, and an unfilled text box.
|
||||
let ellipse = sp(&[fsp(3, 1026, 0xA00), fopt(&[(0x0181, 0x0000_00FF), (0x01C0, 0x00FF_0000), (0x01CB, 25_400)], &[])]);
|
||||
let tb = sp(&[fsp(202, 1027, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
|
||||
let art = art_content(&[], &[ellipse, tb]);
|
||||
let spas =
|
||||
plc(&[1, 2, 4], &[spa(1026, [0, 0, 1440, 1440], spa_flags(2, 2, 3, true)), spa(1027, [0, 0, 2880, 720], spa_flags(0, 0, 1, false))].concat());
|
||||
// The text box story: an FTXBXS whose lid is the shape's spid (cTxbx, cReusable, fReusable,
|
||||
// reserved, lid, txidUndo).
|
||||
let ftxbxs =
|
||||
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &1027u32.to_le_bytes(), &0i32.to_le_bytes()]
|
||||
.concat();
|
||||
let txbx = plc(&[0, 7], &ftxbxs);
|
||||
let text = "A\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
// The text box story is stored as the subdocument right after the main text; the endnote
|
||||
// slot of the fixture writes it there and the FIB is then patched to call it the text box
|
||||
// subdocument (ccpEdn = 0, ccpTxbx = 7).
|
||||
let spec =
|
||||
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
|
||||
let file = patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)]);
|
||||
let doc = read(&file).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects);
|
||||
let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0));
|
||||
assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0));
|
||||
assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph));
|
||||
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
|
||||
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file.to_vec())).unwrap();
|
||||
let mut streams = Vec::new();
|
||||
for name in ["WordDocument", "0Table", "Data"] {
|
||||
if let Ok(mut s) = comp.open_stream(format!("/{name}")) {
|
||||
let mut v = Vec::new();
|
||||
s.read_to_end(&mut v).unwrap();
|
||||
if name == "WordDocument" {
|
||||
for (o, x) in at {
|
||||
v[*o..*o + 4].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
}
|
||||
streams.push((name, v));
|
||||
}
|
||||
}
|
||||
let refs: Vec<(&str, &[u8])> = streams.iter().map(|(n, v)| (*n, v.as_slice())).collect();
|
||||
super::cfb_file(&refs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
// A 2×1 24-bit DIB (BITMAPINFOHEADER, rows padded to 4 bytes).
|
||||
let mut dib = Vec::new();
|
||||
for v in [40u32, 2, 1] {
|
||||
dib.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
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(&[0, 0, 255, 0, 255, 0, 0, 0]);
|
||||
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (dib_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
|
||||
assert_eq!(blip.ext, "png");
|
||||
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
|
||||
|
||||
// A deflated WMF stays a WMF (the metafile renderer draws it).
|
||||
let wmf: Vec<u8> = [1u16, 9, 0x300].iter().flat_map(|v| v.to_le_bytes()).chain([0u8; 12]).collect();
|
||||
let packed = miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6);
|
||||
let mut hdr = Vec::new();
|
||||
hdr.extend_from_slice(&(wmf.len() as u32).to_le_bytes());
|
||||
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
|
||||
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
|
||||
let (wmf_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
|
||||
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_groups_and_lying_lengths_are_bounded() {
|
||||
// 2000 nested group containers: the walk stops at its depth cap (no stack overflow).
|
||||
let mut g = sp(&[fsp(1, 2000, 0)]);
|
||||
for _ in 0..2_000 {
|
||||
g = container(0xF003, &[sp(&[fsp(0, 2001, 1)]), g]);
|
||||
}
|
||||
let _ = officeart::content(&art_content(&[], &[g]));
|
||||
// A metafile claiming 4 GB uncompressed with garbage data, and an FBSE pointing far out.
|
||||
let mut hdr = vec![0xFFu8; 4];
|
||||
hdr.extend_from_slice(&[0u8; 24]);
|
||||
hdr.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
hdr.extend_from_slice(&[0, 0]);
|
||||
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
assert!(officeart::blip(&emf, 1 << 20).is_none());
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
|
||||
#[test]
|
||||
fn junk_drawings_never_panic(art in proptest::collection::vec(any::<u8>(), 0..2048), spas in proptest::collection::vec(any::<u8>(), 0..512), data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let text = "A\u{8}\u{1}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let picf = picf_shape(&data);
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
data_stream: picf,
|
||||
chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))],
|
||||
extra_pairs: vec![(40, spas), (50, art.clone()), (56, art)],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let _ = read(&struct_doc(&spec));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ mod read;
|
||||
mod smart_art;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
@@ -28,7 +27,6 @@ pub use crypt::{
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
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.
|
||||
|
||||
@@ -19,12 +19,10 @@ pub mod rt {
|
||||
pub const CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
|
||||
pub const APP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
|
||||
pub const CUSTOM: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
|
||||
pub const CUSTOM_XML_PROPS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps";
|
||||
pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
|
||||
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";
|
||||
@@ -41,7 +39,6 @@ pub mod rt {
|
||||
pub const DIAGRAM_LAYOUT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout";
|
||||
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
|
||||
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
||||
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
}
|
||||
|
||||
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
|
||||
@@ -58,9 +55,6 @@ 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";
|
||||
/// 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";
|
||||
/// 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.
|
||||
|
||||
@@ -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`…).
|
||||
|
||||
@@ -6,45 +6,20 @@
|
||||
//! 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;
|
||||
|
||||
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
|
||||
|
||||
use super::Reader;
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
|
||||
};
|
||||
use crate::package::{ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep custom XML stores reached from the main document or package, without evaluating
|
||||
/// bindings. A bibliography already loaded into the source model belongs to its writer.
|
||||
pub(super) fn keep_custom_xml(&mut self, package: &Rels, document: &Rels) {
|
||||
let mut roots = String::new();
|
||||
for (owner, rels) in [("P", package), ("D", document)] {
|
||||
for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) {
|
||||
if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) {
|
||||
continue;
|
||||
}
|
||||
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
|
||||
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(CUSTOM_XML_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).
|
||||
@@ -74,7 +49,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;
|
||||
}
|
||||
|
||||
@@ -219,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" => {
|
||||
|
||||
+7
-250
@@ -36,20 +36,6 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// 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;
|
||||
/// Bibliography sources kept from a file (Word's own source manager holds far fewer).
|
||||
const MAX_SOURCES: usize = 10_000;
|
||||
/// Custom XML parts looked at for bibliography sources.
|
||||
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
|
||||
/// Characters kept of a bibliography's citation style name.
|
||||
const MAX_STYLE_NAME: usize = 64;
|
||||
|
||||
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
|
||||
#[derive(Default)]
|
||||
struct BibSeen {
|
||||
tags: HashSet<String>,
|
||||
untagged: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
|
||||
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
|
||||
|
||||
@@ -161,10 +147,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);
|
||||
}
|
||||
if let Some(p) = part(rt::THEME) {
|
||||
r.read_theme(&p);
|
||||
}
|
||||
@@ -199,31 +181,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
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
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
let mut bib_parts: Vec<String> = Vec::new();
|
||||
for rel in root_rels.list.iter().chain(rels.list.iter()) {
|
||||
if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) {
|
||||
bib_parts.push(rel.target.clone());
|
||||
if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 1..=16 {
|
||||
let path = format!("customXml/item{i}.xml");
|
||||
if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) {
|
||||
bib_parts.push(path);
|
||||
}
|
||||
}
|
||||
let mut seen_sources = BibSeen::default();
|
||||
for path in &bib_parts {
|
||||
if r.doc.sources.len() >= MAX_SOURCES {
|
||||
break;
|
||||
}
|
||||
lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources));
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -233,9 +190,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
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);
|
||||
}
|
||||
@@ -257,7 +213,6 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||||
r.read_vba_related(&v);
|
||||
}
|
||||
r.keep_custom_xml(&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));
|
||||
@@ -390,25 +345,8 @@ 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.
|
||||
/// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading
|
||||
/// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it.
|
||||
fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
|
||||
@@ -416,23 +354,12 @@ fn toc_field_to_heading(body: &mut Blocks) {
|
||||
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
continue;
|
||||
}
|
||||
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 }) 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,
|
||||
};
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
let Some((field, props)) = body[i].as_para().and_then(|p| match p.object_at(0) {
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => Some((f.clone(), p.props_of_char(0).clone())),
|
||||
_ => None,
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
@@ -444,32 +371,7 @@ fn toc_field_to_heading(body: &mut Blocks) {
|
||||
}
|
||||
|
||||
fn is_toc(instr: &str) -> bool {
|
||||
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// The actual matching end must also be the end the styled writer would choose: at the end
|
||||
/// of the last contiguous TOC entry, with no section break or trailing content outside it.
|
||||
fn toc_range_end(body: &Blocks, start: usize) -> Option<usize> {
|
||||
let entry =
|
||||
|p: &wordcraft_doc::Paragraph| p.section.is_none() && p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading");
|
||||
let mut depth = 0_usize;
|
||||
for (i, b) in body.iter().enumerate().skip(start) {
|
||||
let p = b.as_para().filter(|p| entry(p))?;
|
||||
for (off, o) in p.object_offsets().into_iter().zip(&p.objects) {
|
||||
match o {
|
||||
InlineObject::FieldStart { .. } => depth = depth.checked_add(1)?,
|
||||
InlineObject::FieldEnd => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
let following_entry = body.get(i + 1).and_then(|b| b.as_para()).is_some_and(entry);
|
||||
return (off.checked_add(OBJ.len_utf8()) == Some(p.len()) && !following_entry).then_some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -792,25 +694,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;
|
||||
}
|
||||
self.doc.styles = sheet;
|
||||
Ok(())
|
||||
}
|
||||
@@ -994,8 +877,6 @@ 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" => {
|
||||
// Only Word's own setting: another `w:uri` names a different application's.
|
||||
let mode = k
|
||||
@@ -1186,130 +1067,6 @@ impl Reader<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
|
||||
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
|
||||
fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
if root.local() != "Sources" {
|
||||
return Ok(());
|
||||
}
|
||||
if self.doc.settings.bib_style.is_none()
|
||||
&& let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim())
|
||||
&& !name.is_empty()
|
||||
{
|
||||
self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect());
|
||||
}
|
||||
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
|
||||
let parse_persons = |parent: &El| -> String {
|
||||
let mut names = Vec::new();
|
||||
let person_els: Vec<&El> = {
|
||||
let in_lists: Vec<&El> =
|
||||
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
|
||||
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
|
||||
};
|
||||
for name_el in person_els {
|
||||
let last = child_text(name_el, "Last");
|
||||
let first = child_text(name_el, "First");
|
||||
let middle = child_text(name_el, "Middle");
|
||||
let name = if !first.is_empty() && !middle.is_empty() {
|
||||
format!("{last}, {first} {middle}")
|
||||
} else if !first.is_empty() {
|
||||
format!("{last}, {first}")
|
||||
} else {
|
||||
last
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.join("; ")
|
||||
};
|
||||
|
||||
for src_el in root.els().filter(|e| e.local() == "Source") {
|
||||
if self.doc.sources.len() >= MAX_SOURCES {
|
||||
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth =
|
||||
a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
let year = child_text(src_el, "Year");
|
||||
let month = child_text(src_el, "Month");
|
||||
let day = child_text(src_el, "Day");
|
||||
let city = child_text(src_el, "City");
|
||||
let publisher = child_text(src_el, "Publisher");
|
||||
let journal = child_text(src_el, "JournalName");
|
||||
let edition = child_text(src_el, "Edition");
|
||||
let volume = child_text(src_el, "Volume");
|
||||
let issue = child_text(src_el, "Issue");
|
||||
let pages = child_text(src_el, "Pages");
|
||||
let url = child_text(src_el, "URL");
|
||||
let accessed = match child_text(src_el, "Accessed") {
|
||||
a if !a.is_empty() => a,
|
||||
_ => {
|
||||
let y = child_text(src_el, "YearAccessed");
|
||||
let m = child_text(src_el, "MonthAccessed");
|
||||
let d = child_text(src_el, "DayAccessed");
|
||||
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
|
||||
format!("{y}-{m}-{d}")
|
||||
} else if !y.is_empty() && !m.is_empty() {
|
||||
format!("{y}-{m}")
|
||||
} else {
|
||||
y
|
||||
}
|
||||
}
|
||||
};
|
||||
let doi = match child_text(src_el, "DOI") {
|
||||
d if !d.is_empty() => d,
|
||||
_ => child_text(src_el, "StandardNumber"),
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
if fresh {
|
||||
self.doc.sources.push(wordcraft_doc::Source {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
publisher,
|
||||
journal,
|
||||
volume,
|
||||
pages,
|
||||
url,
|
||||
editor,
|
||||
edition,
|
||||
issue,
|
||||
month,
|
||||
day,
|
||||
accessed,
|
||||
doi,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_core(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
let c = &mut self.doc.core;
|
||||
|
||||
@@ -14,28 +14,6 @@ use wordcraft_doc::section::{
|
||||
|
||||
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.
|
||||
@@ -55,27 +33,6 @@ 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())
|
||||
@@ -232,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() {
|
||||
@@ -262,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)),
|
||||
@@ -292,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();
|
||||
@@ -538,7 +489,6 @@ 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("")),
|
||||
"w:vAlign" => {
|
||||
|
||||
+27
-163
@@ -14,14 +14,11 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
|
||||
use super::Reader;
|
||||
use super::props::{sectpr, tcpr, trpr};
|
||||
use super::shape_style::{preset_kind, shape_paint, vml_color};
|
||||
use crate::package::{ContentTypes, Rels, rt};
|
||||
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";
|
||||
|
||||
/// Deepest table nesting we build (deeper tables become plain paragraphs).
|
||||
const MAX_TABLE_DEPTH: usize = 24;
|
||||
/// Deepest field nesting we track.
|
||||
@@ -86,9 +83,9 @@ fn enclosure(f: &FieldState) -> Option<wordcraft_doc::eq::Enclosure> {
|
||||
}
|
||||
|
||||
/// Fields kept as ranges: those whose result another program writes, with formatting and often
|
||||
/// several paragraphs (TOCs and citation managers' `ADDIN` fields).
|
||||
/// several paragraphs (citation managers' `ADDIN` fields: Zotero, Mendeley, EndNote).
|
||||
fn is_range_instr(instr: &str) -> bool {
|
||||
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// The last paragraph of a block list: its last block, or inside a final table the last
|
||||
@@ -166,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"))
|
||||
}
|
||||
@@ -334,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`
|
||||
@@ -357,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()
|
||||
@@ -511,10 +500,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_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);
|
||||
}
|
||||
}
|
||||
@@ -639,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);
|
||||
@@ -680,9 +661,7 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_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);
|
||||
}
|
||||
@@ -893,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.
|
||||
@@ -903,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") {
|
||||
@@ -933,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));
|
||||
@@ -950,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
|
||||
};
|
||||
@@ -976,45 +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)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1023,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());
|
||||
@@ -1056,7 +978,6 @@ impl Reader<'_> {
|
||||
None => Vec::new(),
|
||||
},
|
||||
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
|
||||
GraphicKind::Ink => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1166,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 {
|
||||
@@ -1240,15 +1160,6 @@ 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();
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
|
||||
@@ -1268,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`]).
|
||||
@@ -1312,35 +1217,10 @@ impl Reader<'_> {
|
||||
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((tb, t)) = vml_textbox(shape, 0)
|
||||
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);
|
||||
let story = Some(self.read_textbox(sc, t, rels));
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
@@ -1617,18 +1497,6 @@ fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
@@ -1641,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;
|
||||
}
|
||||
@@ -1691,7 +1558,6 @@ 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));
|
||||
@@ -1783,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,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
|
||||
|
||||
@@ -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(¬e, &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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -13,10 +13,8 @@ use wordcraft_doc::graphic::Embedded;
|
||||
use wordcraft_doc::para::Float;
|
||||
|
||||
use super::PartRels;
|
||||
use crate::package::{
|
||||
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::package::{EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, VBA_PROJECT_PART, VBA_RELATED, relative};
|
||||
use crate::units::emu;
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
|
||||
/// Most bytes written for copies of parts (objects pasted more than once).
|
||||
@@ -29,9 +27,6 @@ const PART_COST: u64 = 4096;
|
||||
/// Where the stories' parts live: every story this writer makes is a part in `word/`.
|
||||
const STORY_PART: &str = "word/document.xml";
|
||||
|
||||
/// The font table kept from a file (read by `Reader::keep_part_tree`).
|
||||
const FONT_TABLE_PART: &str = "word/fontTable.xml";
|
||||
|
||||
/// The embedded-object parts of a package being written.
|
||||
pub(crate) struct EmbedWriter<'d> {
|
||||
manifest: EmbeddedManifest,
|
||||
@@ -69,10 +64,6 @@ impl<'d> EmbedWriter<'d> {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
|
||||
// The bibliography writer uses these names when sources are present (#532).
|
||||
if !doc.sources.is_empty() {
|
||||
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
|
||||
}
|
||||
// The macro project's parts keep their names (see `write_as`).
|
||||
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
|
||||
for line in String::from_utf8_lossy(m).lines() {
|
||||
@@ -93,14 +84,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 {
|
||||
@@ -118,9 +101,6 @@ impl<'d> EmbedWriter<'d> {
|
||||
|
||||
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
|
||||
fn alloc(&mut self, orig: &str) -> String {
|
||||
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
|
||||
// part needs a different base name, otherwise every candidate would be reserved.
|
||||
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
|
||||
let lower = orig.to_ascii_lowercase();
|
||||
if !Self::reserved(&lower) && self.taken.insert(lower) {
|
||||
return orig.to_string();
|
||||
@@ -177,25 +157,6 @@ impl<'d> EmbedWriter<'d> {
|
||||
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
|
||||
}
|
||||
|
||||
/// Reattach implicit custom XML roots, sharing their kept parts across both owners.
|
||||
pub fn custom_xml_rels(&mut self, owner: &str, rels: &mut PartRels) {
|
||||
let Some(roots) = self.passthrough.get(CUSTOM_XML_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 || !rel_is(kind, rt::CUSTOM_XML) {
|
||||
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();
|
||||
@@ -262,12 +223,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;
|
||||
@@ -322,7 +277,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:
|
||||
@@ -454,51 +409,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.
|
||||
@@ -589,9 +499,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();
|
||||
|
||||
@@ -170,7 +170,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let mut overrides: Vec<(String, String)> = Vec::new();
|
||||
let mut rels = PartRels::default();
|
||||
let mut root = PartRels::default();
|
||||
|
||||
// Comments get paragraph ids before any story is written.
|
||||
let mut comment_paras: Vec<(u32, String)> = Vec::new();
|
||||
@@ -293,14 +292,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);
|
||||
@@ -370,8 +367,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 {
|
||||
@@ -385,9 +380,7 @@ 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);
|
||||
// Parts of embedded objects and custom XML stores kept from the file.
|
||||
// Parts of charts, diagrams and OLE objects kept from a file, as the objects written need them.
|
||||
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
|
||||
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
|
||||
}
|
||||
@@ -434,6 +427,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
entries.insert(1, ("word/_rels/document.xml.rels".into(), rels.xml()));
|
||||
|
||||
// Package-level parts.
|
||||
let mut root = PartRels::default();
|
||||
root.add(rt::OFFICE_DOC, "word/document.xml", false);
|
||||
root.add(rt::CORE, "docProps/core.xml", false);
|
||||
root.add(rt::APP, "docProps/app.xml", false);
|
||||
@@ -446,27 +440,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
entries.push(("docProps/custom.xml".into(), crate::custom::write(&doc.custom_props)));
|
||||
overrides.push(("/docProps/custom.xml".into(), "application/vnd.openxmlformats-officedocument.custom-properties+xml".into()));
|
||||
}
|
||||
if !doc.sources.is_empty() {
|
||||
root.add(rt::CUSTOM_XML, "customXml/item1.xml", false);
|
||||
let mut item_rels = PartRels::default();
|
||||
item_rels.add(rt::CUSTOM_XML_PROPS, "itemProps1.xml", false);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"customXml/item1.xml",
|
||||
bibliography_xml(doc),
|
||||
"application/vnd.openxmlformats-officedocument.customXml+xml",
|
||||
item_rels,
|
||||
);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"customXml/itemProps1.xml",
|
||||
bibliography_props_xml(),
|
||||
"application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
|
||||
PartRels::default(),
|
||||
);
|
||||
}
|
||||
entries.insert(0, ("_rels/.rels".into(), root.xml()));
|
||||
|
||||
// Content types.
|
||||
@@ -893,13 +866,6 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
w.open("w:settings", &ns_refs);
|
||||
w.empty("w:zoom", &[("w:percent", "100")]);
|
||||
// §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() {
|
||||
w.empty("w:displayBackgroundShape", &[]);
|
||||
}
|
||||
@@ -1038,28 +1004,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",
|
||||
@@ -1074,10 +1020,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() {
|
||||
@@ -1112,211 +1058,3 @@ fn app_xml(doc: &Document) -> Vec<u8> {
|
||||
w.close("Properties");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn to_source_type(kind: &str) -> &str {
|
||||
match kind.to_ascii_lowercase().as_str() {
|
||||
"book" => "Book",
|
||||
"booksection" | "chapter" => "BookSection",
|
||||
"article" | "journal" | "journalarticle" => "JournalArticle",
|
||||
"periodical" | "articleinaperiodical" => "ArticleInAPeriodical",
|
||||
"website" | "site" | "internetsite" => "InternetSite",
|
||||
"documentfrominternetsite" => "DocumentFromInternetSite",
|
||||
"electronicsource" => "ElectronicSource",
|
||||
"report" => "Report",
|
||||
"conference" | "proceedings" | "conferenceproceedings" => "ConferenceProceedings",
|
||||
"case" => "Case",
|
||||
"art" => "Art",
|
||||
"film" => "Film",
|
||||
"interview" => "Interview",
|
||||
"patent" => "Patent",
|
||||
"performance" => "Performance",
|
||||
"soundrecording" => "SoundRecording",
|
||||
"standard" => "Standard",
|
||||
"thesis" => "Thesis",
|
||||
"other" | "misc" => "Misc",
|
||||
_ => "Book",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_source_type(st: &str) -> String {
|
||||
match st.trim() {
|
||||
"Book" | "book" => "book".into(),
|
||||
"BookSection" | "booksection" => "bookSection".into(),
|
||||
"JournalArticle" | "journalarticle" | "article" => "article".into(),
|
||||
"ArticleInAPeriodical" | "articleinaperiodical" => "periodical".into(),
|
||||
"InternetSite" | "internetsite" | "website" => "website".into(),
|
||||
"DocumentFromInternetSite" | "documentfrominternetsite" => "website".into(),
|
||||
"ElectronicSource" | "electronicsource" => "electronicsource".into(),
|
||||
"Report" | "report" => "report".into(),
|
||||
"ConferenceProceedings" | "conferenceproceedings" => "conference".into(),
|
||||
"Case" | "case" => "case".into(),
|
||||
"SoundRecording" | "soundrecording" => "soundrecording".into(),
|
||||
"Film" | "film" => "film".into(),
|
||||
"Interview" | "interview" => "interview".into(),
|
||||
"Patent" | "patent" => "patent".into(),
|
||||
"Performance" | "performance" => "performance".into(),
|
||||
"Standard" | "standard" => "standard".into(),
|
||||
"Thesis" | "thesis" => "thesis".into(),
|
||||
"Misc" | "misc" => "other".into(),
|
||||
"" => "book".into(),
|
||||
other => other.to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's citation style, APA when it has none, and a style sheet file name made from
|
||||
/// it (`\APA.XSL`, `\MLA.XSL`).
|
||||
fn bib_style(doc: &Document) -> (&str, String) {
|
||||
let name = doc.settings.bib_style.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or("APA");
|
||||
let file: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
(name, format!("\\{}.XSL", if file.is_empty() { "APA" } else { &file }))
|
||||
}
|
||||
|
||||
fn write_name_list(w: &mut W, names: &str) {
|
||||
w.open("b:NameList", &[]);
|
||||
let persons: Vec<_> = names.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
for a in persons {
|
||||
w.open("b:Person", &[]);
|
||||
// Parse "Last, First" or "Last, First Middle"
|
||||
let (last, first_middle) = match a.split_once(',') {
|
||||
Some((l, f)) => (l.trim(), f.trim()),
|
||||
None => (a, ""),
|
||||
};
|
||||
w.leaf("b:Last", &[], last);
|
||||
if !first_middle.is_empty() {
|
||||
let parts: Vec<&str> = first_middle.split_whitespace().collect();
|
||||
if let Some(first) = parts.first() {
|
||||
w.leaf("b:First", &[], first);
|
||||
}
|
||||
if let Some(slice) = parts.get(1..).filter(|s| !s.is_empty()) {
|
||||
let mid = slice.join(" ");
|
||||
w.leaf("b:Middle", &[], &mid);
|
||||
}
|
||||
}
|
||||
w.close("b:Person");
|
||||
}
|
||||
w.close("b:NameList");
|
||||
}
|
||||
|
||||
fn split_accessed_date(s: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return (None, None, None);
|
||||
}
|
||||
let parts: Vec<&str> = if s.contains('-') {
|
||||
s.split('-').collect()
|
||||
} else if s.contains('/') {
|
||||
s.split('/').collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let [y, m, d] = parts.as_slice() {
|
||||
(Some(y.trim()), Some(m.trim()), Some(d.trim()))
|
||||
} else if let [y, m] = parts.as_slice() {
|
||||
(Some(y.trim()), Some(m.trim()), None)
|
||||
} else {
|
||||
(Some(s), None, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn bibliography_xml(doc: &Document) -> Vec<u8> {
|
||||
let (style_name, selected) = bib_style(doc);
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"b:Sources",
|
||||
&[
|
||||
("SelectedStyle", selected.as_str()),
|
||||
("StyleName", style_name),
|
||||
("xmlns:b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
],
|
||||
);
|
||||
for src in &doc.sources {
|
||||
w.open("b:Source", &[]);
|
||||
w.leaf("b:Tag", &[], &src.tag);
|
||||
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
|
||||
// Author & Editor
|
||||
if !src.author.is_empty() || !src.editor.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
if !src.author.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
write_name_list(&mut w, &src.author);
|
||||
w.close("b:Author");
|
||||
}
|
||||
if !src.editor.is_empty() {
|
||||
w.open("b:Editor", &[]);
|
||||
write_name_list(&mut w, &src.editor);
|
||||
w.close("b:Editor");
|
||||
}
|
||||
w.close("b:Author");
|
||||
}
|
||||
if !src.title.is_empty() {
|
||||
w.leaf("b:Title", &[], &src.title);
|
||||
}
|
||||
if !src.year.is_empty() {
|
||||
w.leaf("b:Year", &[], &src.year);
|
||||
}
|
||||
if !src.month.is_empty() {
|
||||
w.leaf("b:Month", &[], &src.month);
|
||||
}
|
||||
if !src.day.is_empty() {
|
||||
w.leaf("b:Day", &[], &src.day);
|
||||
}
|
||||
if !src.city.is_empty() {
|
||||
w.leaf("b:City", &[], &src.city);
|
||||
}
|
||||
if !src.publisher.is_empty() {
|
||||
w.leaf("b:Publisher", &[], &src.publisher);
|
||||
}
|
||||
if !src.journal.is_empty() {
|
||||
w.leaf("b:JournalName", &[], &src.journal);
|
||||
}
|
||||
if !src.edition.is_empty() {
|
||||
w.leaf("b:Edition", &[], &src.edition);
|
||||
}
|
||||
if !src.volume.is_empty() {
|
||||
w.leaf("b:Volume", &[], &src.volume);
|
||||
}
|
||||
if !src.issue.is_empty() {
|
||||
w.leaf("b:Issue", &[], &src.issue);
|
||||
}
|
||||
if !src.pages.is_empty() {
|
||||
w.leaf("b:Pages", &[], &src.pages);
|
||||
}
|
||||
if !src.url.is_empty() {
|
||||
w.leaf("b:URL", &[], &src.url);
|
||||
}
|
||||
if !src.accessed.is_empty() {
|
||||
let (y, m, d) = split_accessed_date(&src.accessed);
|
||||
if let Some(y) = y.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:YearAccessed", &[], y);
|
||||
}
|
||||
if let Some(m) = m.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:MonthAccessed", &[], m);
|
||||
}
|
||||
if let Some(d) = d.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:DayAccessed", &[], d);
|
||||
}
|
||||
w.leaf("b:Accessed", &[], &src.accessed);
|
||||
}
|
||||
if !src.doi.is_empty() {
|
||||
w.leaf("b:DOI", &[], &src.doi);
|
||||
w.leaf("b:StandardNumber", &[], &src.doi);
|
||||
}
|
||||
w.close("b:Source");
|
||||
}
|
||||
w.close("b:Sources");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn bibliography_props_xml() -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"ds:datastoreItem",
|
||||
&[("xmlns:ds", "http://schemas.openxmlformats.org/officeDocument/2006/customXml"), ("ds:itemID", "{3315B9A8-2323-4554-B8B2-1234567890AB}")],
|
||||
);
|
||||
w.open("ds:schemaRefs", &[]);
|
||||
w.empty("ds:schemaRef", &[("ds:uri", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography")]);
|
||||
w.close("ds:schemaRefs");
|
||||
w.close("ds:datastoreItem");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -537,9 +499,6 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
|
||||
VAlign::Center => w.val("w:vAlign", "center"),
|
||||
VAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if c.hide_mark {
|
||||
w.empty("w:hideMark", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
|
||||
|
||||
@@ -369,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)
|
||||
}
|
||||
|
||||
@@ -525,55 +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);
|
||||
}
|
||||
let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root);
|
||||
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
|
||||
let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic"));
|
||||
if alternate.is_some() && native.is_none() {
|
||||
if inner.is_empty() {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", "wpi")]);
|
||||
}
|
||||
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}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.raw(&native.unwrap_or(&root).to_xml());
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
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);
|
||||
}
|
||||
@@ -892,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");
|
||||
@@ -924,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`).
|
||||
@@ -1037,25 +1019,13 @@ 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");
|
||||
}
|
||||
@@ -1140,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) {
|
||||
@@ -1518,7 +1488,7 @@ const TOC_SDT_OPEN: &str =
|
||||
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>"#;
|
||||
|
||||
fn is_toc(instr: &str) -> bool {
|
||||
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
@@ -1543,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));
|
||||
@@ -1551,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;
|
||||
|
||||
+1
-51
@@ -6,7 +6,7 @@
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::{QName, ResolveResult};
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::reader::NsReader;
|
||||
|
||||
use crate::DocxError;
|
||||
@@ -26,7 +26,6 @@ pub const NAMESPACES: &[(&str, &str)] = &[
|
||||
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
|
||||
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
|
||||
("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
|
||||
("w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
|
||||
("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
|
||||
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
|
||||
@@ -47,7 +46,6 @@ pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/201
|
||||
|
||||
/// Namespaces that only appear in non-body parts.
|
||||
const OTHER_NAMESPACES: &[(&str, &str)] = &[
|
||||
("b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
("rel", "http://schemas.openxmlformats.org/package/2006/relationships"),
|
||||
("ct", "http://schemas.openxmlformats.org/package/2006/content-types"),
|
||||
("cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
|
||||
@@ -82,24 +80,6 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
"?"
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
let prefix = prefix_for(uri, &[]);
|
||||
if prefix != "?" {
|
||||
return prefix;
|
||||
}
|
||||
match uri {
|
||||
b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14",
|
||||
b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se",
|
||||
b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid",
|
||||
b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16",
|
||||
b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex",
|
||||
b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
El(El),
|
||||
@@ -307,28 +287,6 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
Ok(v) => v.into_owned(),
|
||||
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
|
||||
};
|
||||
// Requires contains namespace prefixes, not feature names. Resolve them while
|
||||
// the namespace scope still exists; declarations are omitted from this tree.
|
||||
let val = if el.name == "mc:Choice" && aname == "Requires" {
|
||||
let requirements: Vec<_> = val.split_whitespace().take(65).collect();
|
||||
if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) {
|
||||
"?".to_string()
|
||||
} else {
|
||||
requirements
|
||||
.into_iter()
|
||||
.map(|prefix| {
|
||||
let qualified = format!("{prefix}:requirement");
|
||||
match r.resolver_mut().resolve_attribute(QName(qualified.as_bytes())).0 {
|
||||
ResolveResult::Bound(ns) => requirement_prefix(ns.as_ref()),
|
||||
_ => "?",
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
} else {
|
||||
val
|
||||
};
|
||||
el.attrs.push((aname, val));
|
||||
}
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
@@ -530,14 +488,6 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
let e = parse(x).unwrap();
|
||||
let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect();
|
||||
assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_error() {
|
||||
let mut s = String::new();
|
||||
|
||||
@@ -114,8 +114,8 @@ fn reads_word_style_bidi_markup() {
|
||||
assert_eq!(fa.font.as_deref(), Some("Times New Roman"));
|
||||
assert_eq!(fa.font_cs.as_deref(), Some("B Lotus"));
|
||||
assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0)));
|
||||
// Both explicit values survive, even when they happen to be equal.
|
||||
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true)));
|
||||
// b + bCs alike is stored once: an unset complex-script bold is the same as `bold`.
|
||||
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true)));
|
||||
assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR")));
|
||||
assert_eq!(ps[1].props.bidi, Some(false));
|
||||
let x = &ps[1].runs[0].props;
|
||||
|
||||
@@ -53,30 +53,3 @@ fn character_indents_read_and_round_trip() {
|
||||
let again = props(&wordcraft_docx::read(&docx(&xml[xml.find("<w:body>").unwrap() + 8..xml.find("</w:body>").unwrap()])).unwrap());
|
||||
assert_eq!(again[..3], p[..3]);
|
||||
}
|
||||
|
||||
/// HTML-style automatic paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`, ECMA-376
|
||||
/// §17.3.1.33), common in text pasted from web pages: read, "0" switching an inherited one off,
|
||||
/// and written back.
|
||||
#[test]
|
||||
fn automatic_spacing_reads_and_round_trips() {
|
||||
let body = r#"<w:p><w:pPr><w:spacing w:before="100" w:beforeAutospacing="1" w:after="100" w:afterAutospacing="1"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:spacing w:before="0" w:beforeAutospacing="0" w:after="100" w:afterAutospacing="true"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let p = props(&d);
|
||||
assert_eq!((p[0].space_before_auto, p[0].space_after_auto, p[0].space_before), (Some(true), Some(true), Some(5.0)));
|
||||
assert_eq!((p[1].space_before_auto, p[1].space_after_auto), (Some(false), Some(true)));
|
||||
assert_eq!((p[2].space_before_auto, p[2].space_after_auto), (None, None));
|
||||
let rp = d.styles.resolve_para(&p[0]);
|
||||
assert_eq!((rp.space_before, rp.space_after, rp.before_auto), (14.0, 14.0, true), "automatic spacing is 14 pt");
|
||||
assert_eq!(d.styles.resolve_para(&p[2]).space_after, 5.0);
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
for attr in [r#"w:beforeAutospacing="1""#, r#"w:afterAutospacing="1""#, r#"w:beforeAutospacing="0""#] {
|
||||
assert!(xml.contains(attr), "{attr} missing");
|
||||
}
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(props(&again)[..3], p[..3]);
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Independent complex-script formatting through style inheritance and DOCX round trips.
|
||||
//! Synthetic packages, written here; no external documents or installed fonts are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
|
||||
fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body><w:p><w:pPr>{paragraph_style}<w:bidi/></w:pPr><w:r><w:rPr>{run}</w:rPr><w:t>שָׁלוֹם ABC 123</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!("<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults>{styles}</w:styles>");
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("_rels/.rels", root),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn paragraph(d: &Document) -> &Paragraph {
|
||||
d.body.iter().find_map(|b| b.as_para()).unwrap()
|
||||
}
|
||||
|
||||
fn check_generations(bytes: &[u8], check: impl Fn(&Document)) {
|
||||
let mut d = wordcraft_docx::read(bytes).unwrap();
|
||||
for _ in 0..3 {
|
||||
check(&d);
|
||||
let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paragraph(&next).text, paragraph(&d).text);
|
||||
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
|
||||
assert_eq!(paragraph(&next).props.bidi, Some(true));
|
||||
d = next;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_hebrew_font_size_language_and_flags_survive() {
|
||||
let run = r#"<w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4" w:cs="Arial Hebrew"/><w:b w:val="0"/><w:bCs/><w:i w:val="0"/><w:iCs/><w:sz w:val="24"/><w:szCs w:val="48"/><w:rtl/><w:cs/><w:lang w:val="en-US" w:bidi="he-IL"/>"#;
|
||||
check_generations(&package(run, "", "", ""), |d| {
|
||||
let c = ¶graph(d).runs[0].props;
|
||||
assert_eq!((c.font.as_deref(), c.font_cs.as_deref()), (Some("Source Serif 4"), Some("Arial Hebrew")));
|
||||
assert_eq!((c.size, c.size_cs), (Some(12.0), Some(24.0)));
|
||||
assert_eq!((c.bold, c.bold_cs, c.italic, c.italic_cs), (Some(false), Some(true), Some(false), Some(true)));
|
||||
assert_eq!((c.rtl, c.cs), (Some(true), Some(true)));
|
||||
assert_eq!((c.lang.as_deref(), c.lang_bidi.as_deref()), (Some("en-US"), Some("he-IL")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_direct_properties_leave_complex_script_defaults_inherited() {
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
let run = r#"<w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (None, None, None), "Absent direct values must remain absent when saved");
|
||||
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert_eq!((resolved.size, resolved.bold, resolved.italic), (10.0, false, false));
|
||||
let cs = resolved.complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (18.0, true, true));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_style_properties_leave_complex_script_style_chain_inherited() {
|
||||
let styles = r#"
|
||||
<w:style w:type="paragraph" w:styleId="HebrewBase"><w:name w:val="HebrewBase"/><w:rPr><w:rFonts w:cs="Arial Hebrew"/><w:szCs w:val="36"/><w:bCs/><w:iCs/><w:lang w:bidi="he-IL"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="MixedChild"><w:name w:val="MixedChild"/><w:basedOn w:val="HebrewBase"/><w:rPr><w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="LatinOnly"><w:name w:val="LatinOnly"/><w:rPr><w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>"#;
|
||||
let run = r#"<w:rStyle w:val="LatinOnly"/><w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4"/>"#;
|
||||
check_generations(&package(run, "", styles, r#"<w:pStyle w:val="MixedChild"/>"#), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!((c.font.as_str(), c.size, c.bold, c.italic), ("Source Serif 4", 10.0, false, false));
|
||||
let cs = c.complex();
|
||||
assert_eq!((cs.font.as_str(), cs.size, cs.bold, cs.italic), ("Arial Hebrew", 18.0, true, true));
|
||||
assert_eq!(d.styles.get("HebrewBase").unwrap().chr.lang_bidi.as_deref(), Some("he-IL"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_explicit_complex_script_values_are_not_discarded() {
|
||||
let run = r#"<w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/>"#;
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(false), Some(false)));
|
||||
let cs = d.styles.resolve_char(p.props.style.as_deref(), c).complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_script_direct_properties_leave_latin_defaults_inherited() {
|
||||
let run = r#"<w:szCs w:val="20"/><w:bCs w:val="0"/><w:iCs w:val="0"/><w:rtl w:val="0"/><w:cs w:val="0"/>"#;
|
||||
let defaults = r#"<w:sz w:val="36"/><w:b/><w:i/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size, c.bold, c.italic), (None, None, None));
|
||||
assert_eq!((c.rtl, c.cs), (Some(false), Some(false)));
|
||||
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert_eq!((resolved.size, resolved.bold, resolved.italic), (18.0, true, true));
|
||||
let cs = resolved.complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_formatting_keeps_independent_complex_script_values() {
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
let run = r#"<w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/></w:rPr></w:rPrChange>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
let current = d.styles.resolve_char(None, c).complex();
|
||||
assert_eq!((current.size, current.bold, current.italic), (18.0, true, true));
|
||||
let change = c.fmt_change.as_ref().unwrap();
|
||||
assert_eq!((change.old.size_cs, change.old.bold_cs, change.old.italic_cs), (Some(10.0), Some(false), Some(false)));
|
||||
let old = d.styles.resolve_char(None, &change.old).complex();
|
||||
assert_eq!((old.size, old.bold, old.italic), (10.0, false, false));
|
||||
let mut overlaid = wordcraft_doc::props::CharProps::default();
|
||||
overlaid.overlay(c);
|
||||
assert_eq!(overlaid.fmt_change, c.fmt_change);
|
||||
});
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
//! Original synthetic custom XML stores, written from the public OOXML package contract.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
const DATA: &str =
|
||||
"<?xml version=\"1.0\"?><c:customer xmlns:c=\"urn:original:customer\"><c:name>Atlas Studios</c:name><c:ref>CASE-17</c:ref></c:customer>";
|
||||
const PROPS: &str = r#"<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{22222222-2222-2222-2222-222222222222}"><ds:schemaRefs><ds:schemaRef ds:uri="urn:original:customer"/></ds:schemaRefs></ds:datastoreItem>"#;
|
||||
|
||||
fn rels(rows: &[(&str, &str, &str, bool)]) -> String {
|
||||
let mut out = format!(r#"<Relationships xmlns="{REL_NS}">"#);
|
||||
for (id, kind, target, external) in rows {
|
||||
let mode = if *external { r#" TargetMode="External""# } else { "" };
|
||||
out.push_str(&format!(r#"<Relationship Id="{id}" Type="{RT}{kind}" Target="{target}"{mode}/>"#));
|
||||
}
|
||||
out + "</Relationships>"
|
||||
}
|
||||
|
||||
fn fixture() -> BTreeMap<String, Vec<u8>> {
|
||||
let doc = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Independent edit.</w:t></w:r></w:p><w:p><w:sdt><w:sdtPr><w:id w:val="42"/><w:dataBinding w:xpath="/c:customer/c:name" w:prefixMappings="xmlns:c='urn:original:customer'" w:storeItemID="{22222222-2222-2222-2222-222222222222}"/><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Atlas Studios</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/></Types>"#;
|
||||
[
|
||||
("[Content_Types].xml", types.to_string()),
|
||||
("_rels/.rels", rels(&[("office", "officeDocument", "word/document.xml", false)])),
|
||||
("word/document.xml", doc.to_string()),
|
||||
("word/_rels/document.xml.rels", rels(&[("data", "customXml", "../customXml/item1.xml", false)])),
|
||||
("customXml/item1.xml", DATA.to_string()),
|
||||
("customXml/itemProps1.xml", PROPS.to_string()),
|
||||
("customXml/_rels/item1.xml.rels", rels(&[("properties", "customXmlProps", "itemProps1.xml", false)])),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.into(), s.into_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn zip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in parts {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes).unwrap();
|
||||
assert!(parts.insert(name, bytes).is_none(), "duplicate ZIP entry");
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn text(parts: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||||
String::from_utf8(parts[name].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn edited_cycles(input: &BTreeMap<String, Vec<u8>>, check: impl Fn(&BTreeMap<String, Vec<u8>>)) {
|
||||
let mut doc = wordcraft_docx::read(&zip(input)).unwrap();
|
||||
for _ in 0..2 {
|
||||
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Unrelated edit. ", &Default::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
check(&unzip(&bytes));
|
||||
doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_store_bytes_properties_and_relationships_survive_two_edited_saves() {
|
||||
edited_cycles(&fixture(), |p| {
|
||||
let document = text(p, "word/document.xml");
|
||||
assert!(document.contains("<w:dataBinding"), "the control keeps its store binding");
|
||||
assert!(document.contains(r#"w:storeItemID="{22222222-2222-2222-2222-222222222222}""#));
|
||||
assert!(document.contains(r#"w:xpath="/c:customer/c:name""#));
|
||||
assert_eq!(p.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(p.get("customXml/itemProps1.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(p, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1.xml""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Id="properties""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
assert!(text(p, "[Content_Types].xml").contains("application/vnd.openxmlformats-officedocument.customXmlProperties+xml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_and_document_roots_share_parts_and_keep_multiple_stores() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"_rels/.rels".into(),
|
||||
rels(&[("office", "officeDocument", "word/document.xml", false), ("data", "customXml", "customXml/item1.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[("one", "customXml", "../customXml/item1.xml", false), ("two", "customXml", "../customXml/other.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert("customXml/other.xml".into(), b"<other>Independent store</other>".to_vec());
|
||||
p.insert("customXml/_rels/other.xml.rels".into(), rels(&[("shared", "customXmlProps", "itemProps1.xml", false)]).into_bytes());
|
||||
// A cycle must not cause repeated parts, and an external schema stays external.
|
||||
p.insert(
|
||||
"customXml/_rels/itemProps1.xml.rels".into(),
|
||||
rels(&[("cycle", "customXml", "item1.xml", false), ("schema", "schema", "https://example.invalid/schema", true)]).into_bytes(),
|
||||
);
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/other.xml"), p.get("customXml/other.xml"));
|
||||
assert!(text(out, "_rels/.rels").contains(r#"Target="customXml/item1.xml""#));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/other.xml""#));
|
||||
assert!(text(out, "customXml/_rels/itemProps1.xml.rels").contains(r#"TargetMode="External""#));
|
||||
assert!(text(out, "customXml/_rels/other.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_external_stores_are_not_fetched_or_written() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[
|
||||
("missing", "customXml", "../customXml/missing.xml", false),
|
||||
("external", "customXml", "https://example.invalid/store.xml", true),
|
||||
("valid", "customXml", "../customXml/item1.xml", false),
|
||||
])
|
||||
.into_bytes(),
|
||||
);
|
||||
p.insert("customXml/_rels/item1.xml.rels".into(), rels(&[("missing", "customXmlProps", "missing.xml", false)]).into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
let roots = text(out, "word/_rels/document.xml.rels");
|
||||
assert!(!roots.contains("missing.xml"));
|
||||
assert!(!roots.contains("example.invalid"));
|
||||
assert!(!out.contains_key("customXml/_rels/item1.xml.rels"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stores_do_not_shadow_reserved_bibliography_names() {
|
||||
let mut doc = wordcraft_docx::read(&zip(&fixture())).unwrap();
|
||||
doc.sources.push(wordcraft_doc::Source { tag: "OriginalSource".into(), title: "Original synthetic reference".into(), ..Default::default() });
|
||||
let out = unzip(&wordcraft_docx::write(&doc).unwrap());
|
||||
assert_eq!(out.get("customXml/item1_2.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/itemProps1_2.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(&out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1_2.xml""#));
|
||||
assert!(text(&out, "customXml/_rels/item1_2.xml.rels").contains(r#"Target="itemProps1_2.xml""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_non_utf8_data_and_strict_relationship_types_are_preserved() {
|
||||
let mut p = fixture();
|
||||
let data: Vec<u8> =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?><customer>Atlas Studios</customer>".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
bytes.extend(data);
|
||||
p.insert("customXml/item1.xml".into(), bytes.clone());
|
||||
let strict =
|
||||
rels(&[("strict", "customXml", "../customXml/item1.xml", false)]).replace(RT, "http://purl.oclc.org/ooxml/officeDocument/relationships/");
|
||||
p.insert("word/_rels/document.xml.rels".into(), strict.into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml"), Some(&bytes));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains("http://purl.oclc.org/ooxml/officeDocument/relationships/customXml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_part_targets_are_renamed_instead_of_shadowing_or_looping() {
|
||||
let mut p = fixture();
|
||||
p.insert("word/_rels/document.xml.rels".into(), rels(&[("hostile", "customXml", "../docProps/foreign.xml", false)]).into_bytes());
|
||||
p.insert("docProps/foreign.xml".into(), DATA.as_bytes().to_vec());
|
||||
edited_cycles(&p, |out| {
|
||||
assert!(out.values().any(|b| b == DATA.as_bytes()));
|
||||
assert!(!out.contains_key("docProps/foreign.xml"));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="embeddings/part.xml""#));
|
||||
assert!(text(out, "docProps/core.xml").contains("coreProperties"));
|
||||
});
|
||||
}
|
||||
@@ -97,29 +97,6 @@ fn paras(d: &Document) -> Vec<&Paragraph> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
|
||||
let d = read_body(
|
||||
r#"
|
||||
<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
|
||||
<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r>
|
||||
<w:r><w:instrText> TOC \o "1-1" \h </w:instrText></w:r>
|
||||
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
|
||||
<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:b/></w:rPr><w:t>Intro</w:t></w:r>
|
||||
<w:r><w:tab/></w:r><w:fldSimple w:instr=" PAGEREF _Toc1 \h "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:hyperlink>
|
||||
<w:r><w:fldChar w:fldCharType="end"/></w:r>
|
||||
</w:p>"#,
|
||||
);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].plain_text(), "Contents");
|
||||
assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]);
|
||||
assert_eq!(p[1].plain_text(), "Intro\t1");
|
||||
assert_eq!(p[1].props_of_char(0).bold, Some(true));
|
||||
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
|
||||
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_complex_field_spanning_paragraphs() {
|
||||
let body = r#"
|
||||
@@ -135,13 +112,13 @@ fn toc_complex_field_spanning_paragraphs() {
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 5);
|
||||
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
|
||||
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
|
||||
assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false, code: false });
|
||||
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false });
|
||||
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
|
||||
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
|
||||
assert_eq!(p[1].text, "Body\t2");
|
||||
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2"));
|
||||
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
|
||||
assert_eq!(p[2].text, "");
|
||||
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
|
||||
assert_eq!(p[4].plain_text(), "Page 7");
|
||||
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false });
|
||||
@@ -178,31 +155,6 @@ fn hyperlink_runs_and_wrappers() {
|
||||
assert_eq!(p[1].plain_text(), "in block sdt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() {
|
||||
// Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a
|
||||
// run whose font is Wingdings; plus Symbol and Webdings.
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:t xml:space="preserve">Done </w:t></w:r>
|
||||
<w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr><w:t>J</w:t></w:r>
|
||||
<w:r><w:sym w:font="Symbol" w:char="F061"/><w:sym w:font="Webdings" w:char="F061"/></w:r>
|
||||
</w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = paras(&d);
|
||||
// Stored as Word stores it…
|
||||
assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}");
|
||||
// …read (copied, exported as text) as the symbols shown.
|
||||
assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
// A round trip keeps the original characters and fonts.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let q = paras(&again);
|
||||
assert_eq!(q[0].plain_text(), p[0].plain_text());
|
||||
assert_eq!(q[0].shown_text(), p[0].shown_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_content_choice_and_fallback() {
|
||||
let body = r#"
|
||||
@@ -1171,8 +1123,7 @@ fn formatting_revisions_round_trip() {
|
||||
assert!(xml.contains(parent), "{tag} last: {xml}");
|
||||
}
|
||||
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
|
||||
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
|
||||
assert!(xml.contains(r#"<w:b/><w:bCs/><w:rPrChange w:id="#), "{xml}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(again.body, d.body);
|
||||
assert_eq!(again.last_section, d.last_section);
|
||||
@@ -1538,533 +1489,3 @@ fn vml_lines_and_rectangles() {
|
||||
}
|
||||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
}
|
||||
|
||||
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
|
||||
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
|
||||
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
|
||||
/// and survive saving.
|
||||
#[test]
|
||||
fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||||
let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
|
||||
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
|
||||
off.0, off.1, ext.0, ext.1
|
||||
)
|
||||
};
|
||||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||||
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
|
||||
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
|
||||
let members = [
|
||||
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
|
||||
member(
|
||||
12,
|
||||
false,
|
||||
(914400, 4572000),
|
||||
(4572000, 914400),
|
||||
band,
|
||||
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
|
||||
"<wps:bodyPr/>",
|
||||
),
|
||||
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
|
||||
]
|
||||
.concat();
|
||||
let group = format!(
|
||||
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
|
||||
);
|
||||
let subtitle = anchored_wsp(
|
||||
"Subtitle",
|
||||
5753100,
|
||||
420000,
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
|
||||
"",
|
||||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||||
)
|
||||
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
|
||||
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
|
||||
let check = |d: &Document| {
|
||||
let theme = &d.settings.theme_colors;
|
||||
let objs = ¶s(d)[0].objects;
|
||||
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||||
assert_eq!(children.len(), 3);
|
||||
let shape = |i: usize| match &children[i].obj {
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (kind, fill, free, _) = shape(0);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
|
||||
let (kind, fill, free, _) = shape(1);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
|
||||
let (kind, _, _, story) = shape(2);
|
||||
assert_eq!(kind, ShapeKind::TextBox);
|
||||
let body = d.parts[&story.unwrap()].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
|
||||
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
|
||||
let body = d.parts[sub].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
|
||||
};
|
||||
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// #886: saving dropped Word's DrawingML shapes (rectangles, ellipses, triangles, arrows,
|
||||
/// stars, connectors and their theme fills). Each kind the report lists is read, saved and read
|
||||
/// back with the same kind, fill, outline, size and position, and the saved part still holds a
|
||||
/// `wps:wsp` with its preset geometry.
|
||||
#[test]
|
||||
fn drawingml_shapes_survive_saving() {
|
||||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||||
let geom = |prst: &str| format!(r#"<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>"#);
|
||||
let presets = ["rect", "ellipse", "triangle", "rightArrow", "star5"];
|
||||
let mut body: String = presets.iter().map(|p| anchored_wsp(p, 2009775, 819150, &geom(p), style, "<wps:cNvSpPr/>")).collect();
|
||||
body.push_str(&anchored_wsp(
|
||||
"Connector",
|
||||
2362200,
|
||||
390525,
|
||||
r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#,
|
||||
style,
|
||||
"<wps:cNvCnPr/>",
|
||||
));
|
||||
body.push_str(&anchored_wsp(
|
||||
"Text Box",
|
||||
2324100,
|
||||
571500,
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="7F7F7F"/></a:solidFill></a:ln>"#,
|
||||
"",
|
||||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Floating text box</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||||
));
|
||||
let shapes = |d: &Document| {
|
||||
paras(d)[0]
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
let accent = d.settings.theme_colors[4];
|
||||
let read = shapes(&d);
|
||||
let kinds: Vec<ShapeKind> = read.iter().map(|s| s.0).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
ShapeKind::Rectangle,
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::Triangle,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::StraightConnector,
|
||||
ShapeKind::TextBox
|
||||
]
|
||||
);
|
||||
for s in &read[..5] {
|
||||
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0);
|
||||
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
|
||||
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
|
||||
}
|
||||
assert!(read[5].9, "the connector keeps its arrowhead");
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(shapes(&again), read, "every shape survives saving");
|
||||
assert_eq!(part_text_box(&again), "Floating text box");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert_eq!(xml.matches("<wps:wsp>").count(), 7);
|
||||
for p in presets.iter().chain(&["straightConnector1"]) {
|
||||
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{p}">"#)), "{p} is written");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of the first text box in `d`.
|
||||
fn part_text_box(d: &Document) -> String {
|
||||
let story = paras(d)[0].objects.iter().find_map(|o| match o {
|
||||
InlineObject::Shape { kind: ShapeKind::TextBox, story, .. } => *story,
|
||||
_ => None,
|
||||
});
|
||||
story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_survive_a_round_trip() {
|
||||
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
|
||||
let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]);
|
||||
let bytes = docx(
|
||||
"<w:p><w:r><w:t>Hi</w:t></w:r></w:p>",
|
||||
&[("rId9", "fontTable", "fontTable.xml")],
|
||||
&[
|
||||
("word/fontTable.xml", table),
|
||||
("word/_rels/fontTable.xml.rels", &table_rels),
|
||||
("word/fonts/Roboto-regular.ttf", "regular font bytes"),
|
||||
("word/fonts/Roboto-bold.ttf", "bold font bytes"),
|
||||
],
|
||||
);
|
||||
let out = wordcraft_docx::write(&wordcraft_docx::read(&bytes).unwrap()).unwrap();
|
||||
let files = unzip(&out);
|
||||
assert_eq!(text(&files, "word/fontTable.xml"), table);
|
||||
assert_eq!(text(&files, "word/fonts/Roboto-regular.ttf"), "regular font bytes");
|
||||
assert_eq!(text(&files, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
|
||||
let table_rels = text(&files, "word/_rels/fontTable.xml.rels");
|
||||
assert!(table_rels.contains(r#"Id="rId1""#) && table_rels.contains("fonts/Roboto-regular.ttf"), "{table_rels}");
|
||||
assert!(text(&files, "word/_rels/document.xml.rels").contains("fontTable.xml"));
|
||||
// A second save keeps them too.
|
||||
let again = unzip(&wordcraft_docx::write(&wordcraft_docx::read(&out).unwrap()).unwrap());
|
||||
assert_eq!(text(&again, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
|
||||
}
|
||||
|
||||
// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ----
|
||||
|
||||
const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||||
const INK_XML: &str = r##"<ink xmlns="http://www.w3.org/2003/InkML"><definitions><context xml:id="ctx"><inkSource xml:id="src"><traceFormat><channel name="X" type="integer" units="cm"/><channel name="Y" type="integer" units="cm"/></traceFormat></inkSource></context><brush xml:id="blue"><brushProperty name="color" value="#2563EB"/><brushProperty name="width" value="0.08" units="cm"/></brush></definitions><trace contextRef="#ctx" brushRef="#blue">0 0, 1 1, 2 0</trace></ink>"##;
|
||||
const INK_TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
|
||||
fn ink_docx(fallback: bool, alias: bool) -> Vec<u8> {
|
||||
let content = r#"<q:contentPart xmlns:q="http://schemas.microsoft.com/office/word/2010/wordml" r:id="rInk"><q:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></q:xfrm></q:contentPart>"#;
|
||||
let drawing = format!(
|
||||
r#"<w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Study ink"/><a:graphic><a:graphicData uri="{INK_NS}">{content}</a:graphicData></a:graphic></wp:inline></w:drawing>"#
|
||||
);
|
||||
let prefix = if alias { "pen" } else { "wpi" };
|
||||
let markup = if fallback {
|
||||
format!(
|
||||
r##"<mc:AlternateContent><mc:Choice xmlns:{prefix}="{INK_NS}" Requires="{prefix}">{drawing}</mc:Choice><mc:Fallback><w:pict><v:shape id="preview" style="width:144pt;height:72pt"><v:imagedata r:id="rPreview"/></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"##
|
||||
)
|
||||
} else {
|
||||
drawing
|
||||
};
|
||||
let body = format!("<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r>{markup}</w:r></w:p>");
|
||||
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
|
||||
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
|
||||
if fallback {
|
||||
relationships.push(("rPreview", "image", "media/preview.png"));
|
||||
extra.push(("word/media/preview.png", "original preview bytes"));
|
||||
}
|
||||
docx(&body, &relationships, &extra)
|
||||
}
|
||||
|
||||
fn check_ink(files: &std::collections::BTreeMap<String, Vec<u8>>) -> String {
|
||||
let body = text(files, "word/document.xml");
|
||||
let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}"));
|
||||
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/customXml"));
|
||||
let part = part_at("word", &target);
|
||||
assert_eq!(text(files, &part), INK_XML);
|
||||
assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||||
part
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_survives_unrelated_edit_and_two_saves() {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap();
|
||||
let g = graphics(&d);
|
||||
assert_eq!(g.len(), 1, "native ink must remain a model object");
|
||||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||||
assert_eq!(graphic.kind.noun(), "ink");
|
||||
assert!(graphic.source.is_some());
|
||||
assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame");
|
||||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||||
let Block::Para(p) = p else { panic!() };
|
||||
p.delete(0, p.len()).unwrap();
|
||||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
assert!(text(&files, "word/document.xml").contains("Edited"));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_native_ink_content_part_survives_unrelated_edit_and_two_saves() {
|
||||
let body = r#"<w:p><w:r><w:t>Original</w:t></w:r><w:r><w:contentPart r:id="rInk"/></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(
|
||||
body,
|
||||
&[("rInk", "customXml", "ink/ink1.xml")],
|
||||
&[("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)],
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1, "direct native ink must remain a model object");
|
||||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||||
let Block::Para(p) = p else { panic!() };
|
||||
p.delete(0, 8).unwrap();
|
||||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert!(body.contains("<w:contentPart"), "direct content part must stay direct: {body}");
|
||||
let [id] = attr_values(&body, "w:contentPart", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/customXml"));
|
||||
let part = part_at("word", &target);
|
||||
assert_eq!(text(&files, &part), INK_XML);
|
||||
assert!(text(&files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
|
||||
for alias in [false, true] {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap();
|
||||
let g = graphics(&d);
|
||||
assert_eq!(g.len(), 1, "fallback must not replace native ink");
|
||||
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
|
||||
assert!(
|
||||
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media)))
|
||||
);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert!(body.contains("<mc:AlternateContent>") && body.contains("<mc:Fallback>"));
|
||||
let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/image"));
|
||||
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_missing_part_uses_available_fallback() {
|
||||
let bytes = ink_docx(true, true);
|
||||
let mut files = unzip(&bytes);
|
||||
files.remove("word/ink/ink1.xml");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert!(graphics(&d).is_empty());
|
||||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||||
assert!(files.values().any(|bytes| bytes == b"original preview bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_missing_optional_preview_keeps_native_part() {
|
||||
let mut files = unzip(&ink_docx(true, false));
|
||||
files.remove("word/media/preview.png");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
check_ink(&files);
|
||||
assert!(!text(&files, "word/document.xml").contains("<mc:Fallback>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_requires_the_bound_namespace_and_ink_content_type() {
|
||||
for wrong_namespace in [true, false] {
|
||||
let mut files = unzip(&ink_docx(true, false));
|
||||
let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" };
|
||||
let old = text(&files, name);
|
||||
let changed = if wrong_namespace {
|
||||
old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#)
|
||||
} else {
|
||||
old.replace("application/inkml+xml", "application/xml")
|
||||
};
|
||||
files.insert(name.into(), changed.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
assert!(graphics(&d).is_empty());
|
||||
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_copies_have_independent_parts_and_current_frames() {
|
||||
let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap();
|
||||
let [mut object] = graphics(&d).try_into().unwrap();
|
||||
let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() };
|
||||
(*w, *h) = (288.0, 144.0);
|
||||
*float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() };
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, object.clone(), &Default::default()).unwrap();
|
||||
p.insert_object(p.len(), object, &Default::default()).unwrap();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "w14:contentPart", "r:id");
|
||||
assert_eq!(ids.len(), 2);
|
||||
let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect();
|
||||
assert_ne!(parts[0], parts[1]);
|
||||
for part in parts {
|
||||
assert_eq!(text(&files, &part), INK_XML);
|
||||
}
|
||||
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]);
|
||||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]);
|
||||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]);
|
||||
assert_eq!(body.matches("<wp:posOffset>914400</wp:posOffset>").count(), 2);
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
let g = graphics(&back);
|
||||
assert_eq!(g.len(), 2);
|
||||
for object in g {
|
||||
let InlineObject::Graphic { w, h, float, .. } = object else { panic!() };
|
||||
assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_markup_limit_does_not_write_a_dangling_reference() {
|
||||
let mut files = unzip(&ink_docx(false, false));
|
||||
let body = text(&files, "word/document.xml").replace("<a:graphicData ", &format!(r#"<a:graphicData padding="{}" "#, "x".repeat(1024 * 1024 + 1)));
|
||||
files.insert("word/document.xml".into(), body.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() {
|
||||
let mut files = unzip(&ink_docx(false, false));
|
||||
let body = text(&files, "word/document.xml")
|
||||
.replace("<wp:docPr", r#"<wp:effectExtent l="55468" t="512668" r="55468" b="512668"/><wp:docPr"#)
|
||||
.replace("<q:xfrm>", r#"<q:xfrm rot="2700000">"#)
|
||||
.replace(r#"<a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/>"#, r#"<a:off x="127000" y="254000"/><a:ext cx="914400" cy="457200"/>"#);
|
||||
files.insert("word/document.xml".into(), body.into_bytes());
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let [object] = graphics(&d).try_into().unwrap();
|
||||
let InlineObject::Graphic { float, .. } = object else { panic!() };
|
||||
assert_eq!(float.spin().deg, 45.0);
|
||||
assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
check_ink(&files);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]);
|
||||
assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]);
|
||||
assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]);
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Word draws a table of contents' entry links without the Hyperlink character style (measured in
|
||||
/// Word for Windows); the import drops the style from them, keeping the link. A Hyperlink-styled run
|
||||
/// outside a hyperlink, and a hyperlink in another field, keep it.
|
||||
#[test]
|
||||
fn toc_entry_links_lose_the_hyperlink_style() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0066FF"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let field = |instr: &str, inner: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">{instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{inner}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let link =
|
||||
|t: &str| format!(r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>{t}</w:t></w:r></w:hyperlink>"#);
|
||||
let body = [
|
||||
field(r#" TOC \o "1-3" \h \z \u "#, &link("entry")),
|
||||
field(" TOC ", r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>styled</w:t></w:r>"#),
|
||||
field(" QUOTE x ", &link("quoted")),
|
||||
format!("<w:p>{}</w:p>", link("plain")),
|
||||
r#"<w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let run = |word: &str| {
|
||||
paras(&d).into_iter().find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone())).unwrap_or_else(|| panic!("no run with {word}"))
|
||||
};
|
||||
let entry = run("entry");
|
||||
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "a TOC entry: plain, still a link");
|
||||
assert_eq!(run("styled").style.as_deref(), Some("Hyperlink"), "not in a hyperlink: kept");
|
||||
assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept");
|
||||
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
|
||||
}
|
||||
|
||||
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
|
||||
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
|
||||
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
|
||||
/// shows 6.4.5 in NIH's monitoring-plan template).
|
||||
#[test]
|
||||
fn a_list_level_without_a_list_takes_the_style_s_list() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level));
|
||||
assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list");
|
||||
assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles");
|
||||
assert_eq!(num("Loose"), None, "no list in the chain: no numbering");
|
||||
let p = paras(&d);
|
||||
assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2)));
|
||||
assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list");
|
||||
assert_eq!(p[2].props.numbering, None);
|
||||
// Saved and read again it is an ordinary list reference.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
|
||||
}
|
||||
|
||||
/// `w:hideMark` on a cell is read and written back (it was dropped, and the empty rows NIH's
|
||||
/// tables collapse with it showed as blank lines).
|
||||
#[test]
|
||||
fn cell_hide_mark_reads_and_round_trips() {
|
||||
let body = r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:hideMark/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:p/>"#;
|
||||
let hides = |d: &Document| -> Vec<bool> {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| {
|
||||
if let wordcraft_doc::Block::Table(t) = &**b { Some(t.rows[0].cells.iter().map(|c| c.props.hide_mark).collect()) } else { None }
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let d = read_body(body);
|
||||
assert_eq!(hides(&d), [true, false]);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(hides(&again), [true, false]);
|
||||
}
|
||||
|
||||
/// Text frames (`w:framePr` without a drop cap), as NIH's templates put a footer's page number in
|
||||
/// one: read with their size, anchors, alignment and wrap, and written back (they were dropped on
|
||||
/// save).
|
||||
#[test]
|
||||
fn text_frames_read_and_round_trip() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:wrap="around" w:vAnchor="text" w:hAnchor="margin" w:xAlign="center" w:y="1"/></w:pPr><w:r><w:t>3</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:w="2880" w:h="720" w:hRule="exact" w:hSpace="180" w:wrap="notBeside" w:hAnchor="page" w:vAnchor="page" w:x="1440" w:y="2880"/></w:pPr><w:r><w:t>boxed</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:wrap="sideways" w:hAnchor="nowhere" w:w="-5" w:x="NaN"/></w:pPr><w:r><w:t>junk</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="2" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:t>D</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>rop cap text</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
let f = p[0].props.frame.clone().expect("a frame");
|
||||
assert_eq!(
|
||||
(f.wrap.as_str(), f.v_anchor.as_str(), f.h_anchor.as_str(), f.x_align.as_deref(), f.y),
|
||||
("around", "text", "margin", Some("center"), 0.05)
|
||||
);
|
||||
let g = p[1].props.frame.clone().expect("a frame");
|
||||
assert_eq!((g.w, g.h, g.h_exact, g.h_space, g.wrap.as_str(), g.x, g.y), (Some(144.0), Some(36.0), true, 9.0, "notBeside", 72.0, 144.0));
|
||||
let cap = p.iter().find(|q| q.props.drop_cap.is_some()).expect("the drop cap");
|
||||
assert!(cap.props.frame.is_none(), "a drop cap stays a drop cap");
|
||||
let junk = p.iter().find(|q| q.text.contains("junk")).and_then(|q| q.props.frame.clone()).expect("a frame");
|
||||
assert_eq!((junk.wrap.as_str(), junk.h_anchor.as_str(), junk.w, junk.x), ("auto", "page", None, 0.0), "unknown values fall back");
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let q = paras(&again);
|
||||
assert_eq!(q[0].props.frame, p[0].props.frame);
|
||||
assert_eq!(q[1].props.frame, p[1].props.frame);
|
||||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||||
}
|
||||
|
||||
@@ -163,51 +163,6 @@ const SNIPPETS: &[&str] = &[
|
||||
"<w:sectPr/>",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn malformed_or_hostile_bibliography_never_panics() {
|
||||
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
||||
let cases: &[&[u8]] = &[
|
||||
b"<not_sources/>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><unclosed",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source/></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:NameList><b:Person/></b:NameList></b:Author></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag></b:Tag><b:SourceType></b:SourceType><b:Author><b:NameList><b:Person><b:Last>,,,,</b:Last><b:First></b:First></b:Person></b:NameList></b:Author></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:Editor><b:NameList><b:Person><b:Last>Ed</b:Last></b:Person></b:NameList></b:Editor></b:Author><b:Edition>1st</b:Edition><b:Issue>99</b:Issue><b:Month>13</b:Month><b:Day>32</b:Day><b:YearAccessed>2025</b:YearAccessed><b:MonthAccessed>02</b:MonthAccessed><b:DayAccessed>30</b:DayAccessed><b:StandardNumber>doi:10.0/0</b:StandardNumber></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag>Hostile1</b:Tag><b:Editor><b:Person/></b:Editor><b:Accessed>---///---</b:Accessed></b:Source></b:Sources>",
|
||||
b"\xff\xfe<\0b\0:\0S\0",
|
||||
];
|
||||
let root_rels_bib = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
||||
for case in cases {
|
||||
let z = zip(&[("_rels/.rels", root_rels_bib.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", case)]);
|
||||
let res = read(&z);
|
||||
if let Ok(d) = res {
|
||||
let _ = write(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file with a huge number of bibliography sources (duplicates and distinct ones) reads in
|
||||
/// linear time and keeps at most 10,000 of them.
|
||||
#[test]
|
||||
fn hostile_bibliography_source_count_is_capped() {
|
||||
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
||||
let mut sources = String::from(r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" StyleName="MLA">"#);
|
||||
for i in 0..60_000 {
|
||||
sources.push_str(&format!("<b:Source><b:Tag>T{i}</b:Tag><b:Title>Title {i}</b:Title></b:Source>"));
|
||||
// Untagged duplicates all collapse into one source.
|
||||
sources.push_str("<b:Source><b:Title>Same</b:Title></b:Source>");
|
||||
}
|
||||
sources.push_str("</b:Sources>");
|
||||
let root_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
||||
let z = zip(&[("_rels/.rels", root_rels.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", sources.as_bytes())]);
|
||||
let start = std::time::Instant::now();
|
||||
let d = read(&z).unwrap();
|
||||
assert_eq!(d.sources.len(), 10_000);
|
||||
assert_eq!(d.sources.iter().filter(|s| s.tag.is_empty()).count(), 1, "untagged duplicates are kept once");
|
||||
assert_eq!(d.settings.bib_style.as_deref(), Some("MLA"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 300, ..ProptestConfig::default() })]
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Privacy settings (#1062): `w:removePersonalInformation` and `w:removeDateAndTime`
|
||||
//! (ECMA-376 Part 1 §17.15.1.69–70) are kept on save and honoured by the writer. Synthetic
|
||||
//! fixtures written for these tests from the spec; the names are made up.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
|
||||
fn zip(entries: &[(&str, String)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, text) in entries {
|
||||
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, String> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut s = String::new();
|
||||
if f.read_to_string(&mut s).is_ok() {
|
||||
parts.insert(f.name().to_string(), s);
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// A package with two named commenters, a tracked insertion and deletion, the document
|
||||
/// properties' creator and last-modified-by, and `settings` inside `w:settings`.
|
||||
fn fixture(settings: &str) -> Vec<u8> {
|
||||
let body = r#"<w:p><w:commentRangeStart w:id="1"/><w:r><w:t>Clause one.</w:t></w:r><w:commentRangeEnd w:id="1"/><w:r><w:commentReference w:id="1"/></w:r></w:p>
|
||||
<w:p><w:commentRangeStart w:id="2"/><w:ins w:id="10" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added</w:t></w:r></w:ins><w:del w:id="11" w:author="Ona Vettel" w:date="2026-03-05T05:06:07Z"><w:r><w:delText>Removed</w:delText></w:r></w:del><w:commentRangeEnd w:id="2"/><w:r><w:commentReference w:id="2"/></w:r></w:p>"#;
|
||||
let comments = format!(
|
||||
r#"<w:comments {W_NS}><w:comment w:id="1" w:author="Quill Ramsey" w:initials="QR" w:date="2026-03-01T10:00:00Z"><w:p><w:r><w:t>Check this.</w:t></w:r></w:p></w:comment><w:comment w:id="2" w:author="Ona Vettel" w:initials="OV" w:date="2026-03-02T10:00:00Z"><w:p><w:r><w:t>Agreed.</w:t></w:r></w:p></w:comment></w:comments>"#
|
||||
);
|
||||
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Draft terms</dc:title><dc:creator>Quill Ramsey</dc:creator><cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy></cp:coreProperties>"#;
|
||||
let root_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#
|
||||
);
|
||||
let doc_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}comments" Target="comments.xml"/><Relationship Id="rId2" Type="{RT}settings" Target="settings.xml"/></Relationships>"#
|
||||
);
|
||||
zip(&[
|
||||
("_rels/.rels", root_rels),
|
||||
("word/document.xml", format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#)),
|
||||
("word/_rels/document.xml.rels", doc_rels),
|
||||
("word/comments.xml", comments),
|
||||
("word/settings.xml", format!(r#"<w:settings {W_NS}>{settings}</w:settings>"#)),
|
||||
("docProps/core.xml", core.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
const NAMES: [&str; 4] = ["Quill Ramsey", "Ona Vettel", "\"QR\"", "\"OV\""];
|
||||
|
||||
/// Every part of the saved package, joined.
|
||||
fn saved(doc: &wordcraft_doc::Document) -> (BTreeMap<String, String>, String) {
|
||||
let parts = unzip(&wordcraft_docx::write(doc).unwrap());
|
||||
let all = parts.values().cloned().collect::<Vec<_>>().join("\n");
|
||||
(parts, all)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_personal_information_and_dates_on_save() {
|
||||
let doc = wordcraft_docx::read(&fixture("<w:removePersonalInformation/><w:removeDateAndTime/>")).unwrap();
|
||||
assert!(doc.settings.remove_personal_info && doc.settings.remove_date_time);
|
||||
let before = doc.clone();
|
||||
let (parts, all) = saved(&doc);
|
||||
// The open document keeps its real names and dates.
|
||||
assert_eq!(doc, before);
|
||||
assert_eq!(doc.core.creator, "Quill Ramsey");
|
||||
assert!(doc.comments.values().any(|c| c.author == "Ona Vettel" && !c.date.is_empty()));
|
||||
// No name, initials or annotation date anywhere in the file.
|
||||
for name in NAMES {
|
||||
assert!(!all.contains(name), "{name} saved");
|
||||
}
|
||||
assert!(!all.contains("w:date="), "annotation date saved");
|
||||
assert!(!all.contains("2026-03-0"), "annotation date saved");
|
||||
let comments = &parts["word/comments.xml"];
|
||||
assert_eq!(comments.matches(r#"w:author="Author""#).count(), 2, "{comments}");
|
||||
assert_eq!(comments.matches(r#"w:initials="A""#).count(), 2, "{comments}");
|
||||
let body = &parts["word/document.xml"];
|
||||
assert!(body.contains("<w:ins ") && body.contains("<w:del "));
|
||||
assert_eq!(body.matches(r#"w:author="Author""#).count(), 2, "{body}");
|
||||
let core = &parts["docProps/core.xml"];
|
||||
assert!(!core.contains("dc:creator") && !core.contains("cp:lastModifiedBy"), "{core}");
|
||||
assert!(core.contains("Draft terms"), "title kept: {core}");
|
||||
// Both settings are written back (in CT_Settings order, after zoom), so Word keeps honouring them.
|
||||
let settings = &parts["word/settings.xml"];
|
||||
let zoom = settings.find("w:zoom").unwrap();
|
||||
let personal = settings.find("<w:removePersonalInformation/>").unwrap();
|
||||
let dates = settings.find("<w:removeDateAndTime/>").unwrap();
|
||||
assert!(zoom < personal && personal < dates, "{settings}");
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(again.settings.remove_personal_info && again.settings.remove_date_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_privacy_setting_works_alone() {
|
||||
// Dates only: names stay.
|
||||
let doc = wordcraft_docx::read(&fixture("<w:removeDateAndTime/>")).unwrap();
|
||||
let (parts, all) = saved(&doc);
|
||||
assert!(!all.contains("w:date="));
|
||||
assert!(all.contains("Quill Ramsey") && all.contains("Ona Vettel"));
|
||||
assert!(parts["docProps/core.xml"].contains("<dc:creator>Quill Ramsey</dc:creator>"));
|
||||
assert!(!parts["word/settings.xml"].contains("removePersonalInformation"));
|
||||
// Names only: dates stay.
|
||||
let doc = wordcraft_docx::read(&fixture(r#"<w:removePersonalInformation w:val="1"/>"#)).unwrap();
|
||||
let (_, all) = saved(&doc);
|
||||
for name in NAMES {
|
||||
assert!(!all.contains(name), "{name} saved");
|
||||
}
|
||||
assert_eq!(all.matches("w:date=").count(), 4, "two comments and two changes keep their dates");
|
||||
// Off (explicitly or absent): everything is saved as it was.
|
||||
for settings in ["", r#"<w:removePersonalInformation w:val="0"/><w:removeDateAndTime w:val="false"/>"#] {
|
||||
let doc = wordcraft_docx::read(&fixture(settings)).unwrap();
|
||||
assert!(!doc.settings.remove_personal_info && !doc.settings.remove_date_time);
|
||||
let (parts, all) = saved(&doc);
|
||||
assert!(all.contains("Quill Ramsey") && all.contains(r#"w:initials="OV""#));
|
||||
assert_eq!(all.matches("w:date=").count(), 4);
|
||||
assert!(parts["docProps/core.xml"].contains("<cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy>"));
|
||||
assert!(!parts["word/settings.xml"].contains("removeDateAndTime"));
|
||||
}
|
||||
}
|
||||
+34
-219
@@ -11,7 +11,7 @@ use wordcraft_doc::props::{
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block};
|
||||
|
||||
fn rt(doc: &Document) -> Document {
|
||||
let bytes = wordcraft_docx::write(doc).expect("write");
|
||||
@@ -141,8 +141,6 @@ fn every_para_prop_round_trips() {
|
||||
indent_first_chars: Some(-1.0),
|
||||
space_before: Some(6.0),
|
||||
space_after: Some(12.0),
|
||||
space_before_auto: Some(true),
|
||||
space_after_auto: Some(false),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.5)),
|
||||
contextual_spacing: Some(true),
|
||||
mirror_indents: Some(true),
|
||||
@@ -165,20 +163,6 @@ fn every_para_prop_round_trips() {
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
drop_cap: None,
|
||||
frame: Some(wordcraft_doc::props::TextFrame {
|
||||
w: Some(144.0),
|
||||
h: Some(36.0),
|
||||
h_exact: true,
|
||||
x: 0.0,
|
||||
y: 12.0,
|
||||
x_align: Some("right".into()),
|
||||
y_align: None,
|
||||
h_anchor: "margin".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
h_space: 9.0,
|
||||
v_space: 0.0,
|
||||
}),
|
||||
kinsoku: Some(false),
|
||||
word_wrap: Some(false),
|
||||
overflow_punct: Some(false),
|
||||
@@ -701,46 +685,41 @@ fn word_note_mark_round_trips_once() {
|
||||
/// or Word shows them as plain text with no Update Table.
|
||||
#[test]
|
||||
fn toc_field_wraps_its_entries() {
|
||||
for entries in [&[("Intro\t1", "TOC1")][..], &[("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")][..]] {
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
let end = head.len();
|
||||
head.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut blocks = vec![head];
|
||||
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
|
||||
blocks.push(Paragraph::with_text("Body", CharProps::default()));
|
||||
let d = doc_with(blocks);
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
let end = head.len();
|
||||
head.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")];
|
||||
let mut blocks = vec![head];
|
||||
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
|
||||
blocks.push(Paragraph::with_text("Body", CharProps::default()));
|
||||
let d = doc_with(blocks);
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let sdt = xml.find("<w:sdt>").expect("TOC content control");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
|
||||
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
|
||||
let last_entry = xml.find(entries.last().unwrap().0.split('\t').next().unwrap()).unwrap();
|
||||
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
|
||||
assert!(
|
||||
begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(),
|
||||
"field must span the entries: {xml}"
|
||||
);
|
||||
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
|
||||
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
|
||||
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let sdt = xml.find("<w:sdt>").expect("TOC content control");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
|
||||
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
|
||||
let last_entry = xml.find("Detail").unwrap();
|
||||
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
|
||||
assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(), "field must span the entries: {xml}");
|
||||
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
|
||||
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
|
||||
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
|
||||
|
||||
// WordCraft reads its own TOC back unchanged.
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let ps = paras(&r);
|
||||
let expected: Vec<_> = std::iter::once("Contents").chain(entries.iter().map(|(t, _)| *t)).chain(std::iter::once("Body")).collect();
|
||||
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), expected);
|
||||
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
|
||||
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
|
||||
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
|
||||
}
|
||||
// WordCraft reads its own TOC back unchanged.
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let ps = paras(&r);
|
||||
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]);
|
||||
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
|
||||
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
|
||||
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
|
||||
}
|
||||
|
||||
/// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection`
|
||||
@@ -1281,21 +1260,6 @@ fn kitchen_sink() -> Document {
|
||||
blocks.push(Arc::new(Block::Table(t)));
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
d.body = blocks;
|
||||
d.sources.push(Source {
|
||||
tag: "Sink2026".into(),
|
||||
kind: "book".into(),
|
||||
author: "Author, First".into(),
|
||||
title: "Title".into(),
|
||||
year: "2026".into(),
|
||||
editor: "Editor, Chief".into(),
|
||||
edition: "3rd".into(),
|
||||
issue: "2".into(),
|
||||
month: "08".into(),
|
||||
day: "24".into(),
|
||||
accessed: "2026-08-25".into(),
|
||||
doi: "10.1234/sink.2026".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
@@ -1311,7 +1275,6 @@ fn second_generation_is_stable() {
|
||||
assert_eq!(d2.revisions, d1.revisions);
|
||||
assert_eq!(d2.settings, d1.settings);
|
||||
assert_eq!(d2.media, d1.media);
|
||||
assert_eq!(d2.sources, d1.sources);
|
||||
assert_eq!(d2, d1);
|
||||
}
|
||||
|
||||
@@ -1740,154 +1703,6 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
||||
assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bibliography_sources_round_trip() {
|
||||
let mut d = Document::new();
|
||||
d.sources.push(Source {
|
||||
tag: "Rivera2021".into(),
|
||||
kind: "book".into(),
|
||||
author: "Rivera, Alex; Chen, Mei".into(),
|
||||
title: "Shared Spaces".into(),
|
||||
year: "2021".into(),
|
||||
publisher: "Harbor Books".into(),
|
||||
city: "Portland".into(),
|
||||
editor: "Johnson, Robert".into(),
|
||||
edition: "2nd".into(),
|
||||
month: "10".into(),
|
||||
day: "15".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Smith2023".into(),
|
||||
kind: "article".into(),
|
||||
author: "Smith, John David".into(),
|
||||
title: "Modern Typography".into(),
|
||||
year: "2023".into(),
|
||||
journal: "Design Quarterly".into(),
|
||||
volume: "12".into(),
|
||||
pages: "45-60".into(),
|
||||
url: "https://example.com/typo".into(),
|
||||
issue: "4".into(),
|
||||
doi: "10.1000/182".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "WebSite2024".into(),
|
||||
kind: "website".into(),
|
||||
author: "Solo".into(),
|
||||
title: "Open Fonts".into(),
|
||||
year: "2024".into(),
|
||||
url: "https://example.org".into(),
|
||||
accessed: "2024-05-15".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Chapter2025".into(),
|
||||
kind: "bookSection".into(),
|
||||
author: "Lee, Harper".into(),
|
||||
title: "Chapter One".into(),
|
||||
year: "2025".into(),
|
||||
journal: "Anthology of Prose".into(),
|
||||
pages: "1-20".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Misc2026".into(),
|
||||
kind: "other".into(),
|
||||
title: "Archive Manuscript".into(),
|
||||
year: "2026".into(),
|
||||
city: "Oxford".into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
|
||||
// Verify package-root customXml parts exist
|
||||
assert!(zip.by_name("customXml/item1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/itemProps1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok());
|
||||
|
||||
let mut rels_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("_rels/.rels").unwrap(), &mut rels_xml).unwrap();
|
||||
assert!(rels_xml.contains("relationships/customXml"), "{rels_xml}");
|
||||
assert!(rels_xml.contains("Target=\"customXml/item1.xml\""), "{rels_xml}");
|
||||
|
||||
let mut ct_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut ct_xml).unwrap();
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/item1.xml\""), "{ct_xml}");
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/itemProps1.xml\""), "{ct_xml}");
|
||||
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}");
|
||||
assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>JournalArticle</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Issue>4</b:Issue>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:DOI>10.1000/182</b:DOI>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:YearAccessed>2024</b:YearAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{item_xml}");
|
||||
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(back.sources.len(), 5);
|
||||
assert_eq!(back.sources[0].tag, "Rivera2021");
|
||||
assert_eq!(back.sources[0].kind, "book");
|
||||
assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(back.sources[0].title, "Shared Spaces");
|
||||
assert_eq!(back.sources[0].year, "2021");
|
||||
assert_eq!(back.sources[0].publisher, "Harbor Books");
|
||||
assert_eq!(back.sources[0].city, "Portland");
|
||||
assert_eq!(back.sources[0].editor, "Johnson, Robert");
|
||||
assert_eq!(back.sources[0].edition, "2nd");
|
||||
assert_eq!(back.sources[0].month, "10");
|
||||
assert_eq!(back.sources[0].day, "15");
|
||||
|
||||
assert_eq!(back.sources[1].tag, "Smith2023");
|
||||
assert_eq!(back.sources[1].kind, "article");
|
||||
assert_eq!(back.sources[1].author, "Smith, John David");
|
||||
assert_eq!(back.sources[1].title, "Modern Typography");
|
||||
assert_eq!(back.sources[1].journal, "Design Quarterly");
|
||||
assert_eq!(back.sources[1].volume, "12");
|
||||
assert_eq!(back.sources[1].pages, "45-60");
|
||||
assert_eq!(back.sources[1].url, "https://example.com/typo");
|
||||
assert_eq!(back.sources[1].issue, "4");
|
||||
assert_eq!(back.sources[1].doi, "10.1000/182");
|
||||
|
||||
assert_eq!(back.sources[2].tag, "WebSite2024");
|
||||
assert_eq!(back.sources[2].kind, "website");
|
||||
assert_eq!(back.sources[2].author, "Solo");
|
||||
assert_eq!(back.sources[2].title, "Open Fonts");
|
||||
assert_eq!(back.sources[2].url, "https://example.org");
|
||||
assert_eq!(back.sources[2].accessed, "2024-05-15");
|
||||
|
||||
assert_eq!(back.sources[3].tag, "Chapter2025");
|
||||
assert_eq!(back.sources[3].kind, "bookSection");
|
||||
assert_eq!(back.sources[3].author, "Lee, Harper");
|
||||
assert_eq!(back.sources[3].title, "Chapter One");
|
||||
assert_eq!(back.sources[3].journal, "Anthology of Prose");
|
||||
assert_eq!(back.sources[3].pages, "1-20");
|
||||
|
||||
assert_eq!(back.sources[4].tag, "Misc2026");
|
||||
assert_eq!(back.sources[4].kind, "other");
|
||||
assert_eq!(back.sources[4].title, "Archive Manuscript");
|
||||
assert_eq!(back.sources[4].city, "Oxford");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_polygons_round_trip() {
|
||||
use wordcraft_doc::wrap::{WrapPolygon, shape_outline};
|
||||
|
||||
@@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
|
||||
#[test]
|
||||
fn direction_alignment_and_links_round_trip() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None });
|
||||
let b = text_box(&mut d, "", TextBody::default());
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
|
||||
for (i, o) in [a, b, c].into_iter().enumerate() {
|
||||
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
|
||||
}
|
||||
@@ -108,90 +108,3 @@ fn hostile_links_read_without_chains_running_wild() {
|
||||
let b = r.parts.get(&got[0]).unwrap().body;
|
||||
assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
// ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default
|
||||
// (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor`
|
||||
// places the text between the box's top and bottom.
|
||||
let content = r#"<w:txbxContent><w:p><w:r><w:t>Boxed</w:t></w:r></w:p></w:txbxContent>"#;
|
||||
let shape = |style: &str, textbox: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:pict><v:shape style="width:100pt;height:40pt;{style}"><v:textbox {textbox}>{content}</v:textbox></v:shape></w:pict></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let body = [
|
||||
shape("", r#"inset="0,0,0,0""#),
|
||||
shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#),
|
||||
shape("", ""),
|
||||
// A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0;
|
||||
// too large is the most a margin may be.
|
||||
shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#),
|
||||
// `o:insetmode="auto"` leaves the margins to the application.
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
assert_eq!(again, bodies);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_drawings_stay_hidden() {
|
||||
// VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group
|
||||
// member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden.
|
||||
let vml = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:hidden"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
|
||||
let shown = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:visible"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
|
||||
let inline_vml = r#"<w:p><w:r><w:pict><v:rect style="width:20pt;height:20pt;visibility:hidden"/></w:pict></w:r></w:p>"#;
|
||||
let dml = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><wp:docPr id="7" name="Box" hidden="1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p/></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||||
let member = |id: u32, hidden: &str| {
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"{hidden}/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="635000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"/></wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="8" name="Group"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="1270000" cy="635000"/></a:xfrm></wpg:grpSpPr>{}{}</wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
|
||||
member(9, r#" hidden="1""#),
|
||||
member(10, "")
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><w:body>{vml}{shown}{inline_vml}{dml}{group}</w:body></w:document>"#
|
||||
);
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
// Each drawing's flag, a group's members' after it.
|
||||
let hidden = |d: &Document| {
|
||||
let mut out = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| {
|
||||
out.extend(o.frame().map(|(_, _, f)| f.hidden));
|
||||
if let InlineObject::Group { children, .. } = o {
|
||||
out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden));
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let want = [true, false, true, true, false, true, false];
|
||||
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
assert_eq!(hidden(&d), want);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
let marked =
|
||||
|tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count();
|
||||
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
|
||||
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
//! Original field-boundary fixtures from the public OOXML complex-field contract.
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{InlineObject, Path, StoryRef};
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, value) in [
|
||||
("word/document.xml", xml.as_str()),
|
||||
(
|
||||
"_rels/.rels",
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn start(code: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r><w:r><w:instrText>{code}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
const END: &str = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
const PAGE: &str = r#"<w:fldSimple w:instr="PAGEREF Original"><w:r><w:t>7</w:t></w:r></w:fldSimple>"#;
|
||||
const ENTRY: &str = r#"<w:hyperlink w:anchor="Original"><w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>Scope</w:t></w:r><w:r><w:tab/></w:r>"#;
|
||||
|
||||
#[test]
|
||||
fn unstyled_toc_keeps_range_nested_field_and_formatting_through_two_edited_saves() {
|
||||
for split in [false, true] {
|
||||
let content = if split {
|
||||
format!("{ENTRY}{PAGE}</w:hyperlink></w:p><w:p><w:r><w:t>Delivery</w:t></w:r>")
|
||||
} else {
|
||||
format!("{ENTRY}{PAGE}</w:hyperlink>")
|
||||
};
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Before.</w:t></w:r></w:p><w:p>{}{content}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
|
||||
start(r#"TOC \o "1-2" \h"#)
|
||||
);
|
||||
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
for cycle in 0..=2 {
|
||||
let ranges = doc.field_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].instr, r#"TOC \o "1-2" \h"#);
|
||||
assert!(ranges[0].locked);
|
||||
assert_eq!(ranges[0].start.path, Path::top(1));
|
||||
assert_eq!(ranges[0].start.off, 0);
|
||||
assert_eq!(ranges[0].end.path, Path::top(if split { 2 } else { 1 }));
|
||||
let entry = doc.body[1].as_para().unwrap();
|
||||
assert_eq!(entry.plain_text(), "Scope\t7");
|
||||
let props = entry.props_of_char(wordcraft_doc::para::OBJ.len_utf8());
|
||||
assert_eq!(props.bold, Some(true));
|
||||
assert_eq!(props.italic, Some(true));
|
||||
assert_eq!(props.link.as_deref(), Some("#Original"));
|
||||
assert!(
|
||||
entry.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "PAGEREF Original" && result == "7"))
|
||||
);
|
||||
assert_eq!(doc.body.last().unwrap().as_para().unwrap().plain_text(), "After.");
|
||||
if cycle == 2 {
|
||||
break;
|
||||
}
|
||||
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Independent edit. ", &Default::default()).unwrap();
|
||||
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_heading_does_not_expand_partial_custom_or_short_ranges() {
|
||||
let heading = r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>"#;
|
||||
let extra = r#"<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:t>Outside entry.</w:t></w:r></w:p>"#;
|
||||
for (style, prefix, suffix, following) in
|
||||
[("TOC1", "Prefix.", "", ""), ("TOC1", "", "Suffix.", ""), ("TOC1", "", "", extra), ("CompanyContents", "", "", "")]
|
||||
{
|
||||
let body = format!(
|
||||
r#"{heading}<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{prefix}</w:t></w:r>{}<w:r><w:t>Scope</w:t></w:r>{END}<w:r><w:t>{suffix}</w:t></w:r></w:p>{following}<w:p><w:r><w:t>Body.</w:t></w:r></w:p>"#,
|
||||
start("TOC")
|
||||
);
|
||||
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
let text = doc.plain_text(StoryRef::Body);
|
||||
for cycle in 0..=2 {
|
||||
let ranges = doc.field_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1, "{style} {prefix} {suffix} {following}");
|
||||
assert_eq!(ranges[0].start.path, Path::top(1));
|
||||
assert_eq!(ranges[0].end.path, Path::top(1));
|
||||
assert_eq!(ranges[0].start.off, prefix.len());
|
||||
assert_eq!(ranges[0].end.off, prefix.len() + wordcraft_doc::para::OBJ.len_utf8() + "Scope".len());
|
||||
assert_eq!(doc.plain_text(StoryRef::Body), text);
|
||||
if cycle == 2 {
|
||||
break;
|
||||
}
|
||||
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_lowercase_toc_and_multiple_tocs_keep_their_own_ranges() {
|
||||
let body = format!(
|
||||
r#"<w:p><w:fldSimple w:instr="toc \h" w:fldLock="true">{ENTRY}{PAGE}</w:hyperlink></w:fldSimple><w:r><w:t>Between.</w:t></w:r>{}<w:r><w:t>Other result.</w:t></w:r>{END}<w:r><w:t>After.</w:t></w:r></w:p>"#,
|
||||
start(r#"TOC \o "1-1""#)
|
||||
);
|
||||
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
for cycle in 0..=2 {
|
||||
let r = doc.field_ranges(StoryRef::Body);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[0].instr, r#"toc \h"#);
|
||||
assert_eq!(r[1].instr, r#"TOC \o "1-1""#);
|
||||
assert!(r.iter().all(|r| r.locked));
|
||||
assert!(r[0].end.off < r[1].start.off);
|
||||
assert_eq!(doc.plain_text(StoryRef::Body), "Scope\t7Between.Other result.After.");
|
||||
if cycle == 2 {
|
||||
break;
|
||||
}
|
||||
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_in_table_cell_does_not_capture_the_next_cell() {
|
||||
let body = format!(
|
||||
r#"<w:tbl><w:tr><w:tc><w:p>{}{ENTRY}{PAGE}</w:hyperlink>{END}</w:p></w:tc><w:tc><w:p><w:r><w:t>Other cell.</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
|
||||
start("TOC")
|
||||
);
|
||||
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
for cycle in 0..=2 {
|
||||
let ranges = doc.field_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start.path, wordcraft_doc::Path(vec![0, 0, 0, 0]));
|
||||
assert_eq!(ranges[0].end.path, ranges[0].start.path);
|
||||
assert_eq!(doc.body[0].as_table().unwrap().rows[0].cells[1].blocks[0].as_para().unwrap().plain_text(), "Other cell.");
|
||||
if cycle == 2 {
|
||||
break;
|
||||
}
|
||||
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_end_and_excessive_nesting_stay_bounded_and_save_balanced() {
|
||||
for (starts, ends) in [(1, 0), (40, 40)] {
|
||||
let body = format!("<w:p>{}<w:r><w:t>Visible result.</w:t></w:r>{}</w:p>", start("TOC").repeat(starts), END.repeat(ends));
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(!saved.has_unbalanced_field_ranges());
|
||||
assert_eq!(saved.plain_text(StoryRef::Body), "Visible result.");
|
||||
assert!(saved.field_ranges(StoryRef::Body).len() <= 32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
|
||||
let body = format!(
|
||||
r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="TOC1"/><w:sectPr/></w:pPr>{}<w:r><w:t>Scope</w:t></w:r>{END}</w:p><w:p>{}<w:r><w:t>Other.</w:t></w:r>{END}</w:p>"#,
|
||||
start("TOC"),
|
||||
start("TOC_OTHER")
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
for d in [&doc, &wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap()] {
|
||||
let ranges = d.field_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start.path, Path::top(1));
|
||||
assert!(d.body[1].as_para().unwrap().section.is_some());
|
||||
assert!(
|
||||
d.body[2]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "TOC_OTHER" && result == "Other."))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003
|
||||
//! and read back with the 2003 reader.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
|
||||
use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
id.and_then(|i| doc.parts.get(&i))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sample() -> Document {
|
||||
let mut d = Document::new();
|
||||
d.core.title = "Garden <notes> & more".into();
|
||||
d.core.creator = "Ada".into();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
|
||||
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
|
||||
d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
d.last_section.headers.default = Some(head);
|
||||
|
||||
let mut p = Paragraph::new();
|
||||
for (t, c) in [
|
||||
("Plain ", CharProps::default()),
|
||||
(
|
||||
"loud",
|
||||
CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() },
|
||||
),
|
||||
(" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }),
|
||||
] {
|
||||
let off = p.len();
|
||||
p.insert_text(off, t, &c).unwrap();
|
||||
}
|
||||
p.props.align = Some(Align::Center);
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.props.space_after = Some(12.0);
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
|
||||
let at = p.text.find("loud").unwrap();
|
||||
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
|
||||
let mut blocks = vec![para_block(p)];
|
||||
|
||||
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
|
||||
li.props.numbering = Some(NumRef { num, level: 0 });
|
||||
blocks.push(para_block(li));
|
||||
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
blocks.push(para_block(img));
|
||||
|
||||
let mut t = Table::new(2, 2, 300.0);
|
||||
t.rows[0].cells[0] = Cell::with_text("A1");
|
||||
t.rows[0].cells[1] = Cell::with_text("B1");
|
||||
t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0));
|
||||
blocks.push(Arc::new(Block::Table(t)));
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
d.body = blocks;
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_the_2003_reader() {
|
||||
let d = sample();
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
let text = std::str::from_utf8(&xml).expect("utf-8");
|
||||
assert!(text.contains("<w:wordDocument") && text.contains("http://schemas.microsoft.com/office/word/2003/wordml"));
|
||||
assert!(text.contains("<w:binData") && text.contains("<w:hlink w:dest=\"https://example.org/beans?a=1&b=2\""));
|
||||
assert!(text.contains("<w:footnote>") && text.contains("<wx:sect>") && text.contains("<aml:annotation"));
|
||||
assert!(!text.contains("w:szCs") && !text.contains("<w:drawing"));
|
||||
if std::env::var_os("WML2003_DUMP").is_some() {
|
||||
println!("{text}");
|
||||
}
|
||||
assert_eq!(sniff_xml(&xml), XmlKind::WordMl2003);
|
||||
|
||||
let back = read_xml(&xml).expect("read");
|
||||
assert_eq!(back.core.title, "Garden <notes> & more");
|
||||
assert_eq!(back.core.creator, "Ada");
|
||||
let ps = paras(&back);
|
||||
assert_eq!(ps[0].plain_text(), "Plain loud link");
|
||||
assert_eq!(ps[0].props.align, Some(Align::Center));
|
||||
assert_eq!(ps[0].props.indent_left, Some(36.0));
|
||||
assert_eq!(ps[0].props.space_after, Some(12.0));
|
||||
let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap());
|
||||
assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0)));
|
||||
assert!(c.color.is_some());
|
||||
assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2"));
|
||||
let note = ps[0].objects.iter().find_map(|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(part_text(&back, note), "Sown in May.");
|
||||
let comment = back.comments.values().next().expect("comment");
|
||||
assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?"));
|
||||
|
||||
let listed = ps[1];
|
||||
assert!(listed.props.numbering.is_some(), "list paragraph keeps its list");
|
||||
let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize));
|
||||
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
|
||||
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot"));
|
||||
assert_eq!(back.media.len(), 1);
|
||||
|
||||
let table = back.body.iter().find_map(|b| match b.as_ref() {
|
||||
Block::Table(t) => Some(t),
|
||||
_ => None,
|
||||
});
|
||||
let table = table.expect("table");
|
||||
assert_eq!(table.rows.len(), 2);
|
||||
assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1"));
|
||||
assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0)));
|
||||
assert_eq!(part_text(&back, back.last_section.headers.default), "Head");
|
||||
}
|
||||
|
||||
/// Features 2003 XML can't carry drop out without an error, and the text stays.
|
||||
#[test]
|
||||
fn control_characters_and_unsupported_content_do_not_break_the_file() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() });
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
let text = String::from_utf8(xml).expect("utf-8");
|
||||
assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}'));
|
||||
assert!(!text.contains("<w:drawing") && !text.contains("<mc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_and_deep_documents_write() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text(&"word ".repeat(400_000), CharProps::default()))];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
|
||||
// Tables nested deeper than the writers go: no panic, whatever comes out.
|
||||
let mut inner = Cell::with_text("deep");
|
||||
for _ in 0..60 {
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
t.rows[0].cells[0] = inner;
|
||||
inner = Cell::empty();
|
||||
inner.blocks = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = inner.blocks;
|
||||
if let Ok(xml) = write_wordml2003(&d) {
|
||||
let _ = read_xml(&xml);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ File|Backstage|Properties|file.properties
|
||||
File|Backstage|Export PDF|file.exportPdf
|
||||
File|Backstage|Export Image|file.exportPng
|
||||
File|Backstage|Options|file.options
|
||||
File|Options|Numerals|view.numeral
|
||||
File|Backstage|Share|file.share
|
||||
File|Backstage|Protect Document|file.protect
|
||||
File|Backstage|Inspect Document|file.inspect
|
||||
@@ -31,11 +30,9 @@ File|Backstage|Recover Unsaved|file.recover
|
||||
File|Backstage|New from Template|file.newFromTemplate
|
||||
File|Backstage|Save as Template|file.saveTemplate
|
||||
Quick Access|Toolbar|Undo|edit.undo
|
||||
Quick Access|Toolbar|Undo List|edit.undoTo
|
||||
Quick Access|Toolbar|Redo|edit.redo
|
||||
Quick Access|Toolbar|Repeat|edit.repeat
|
||||
Quick Access|Toolbar|AutoSave|file.autosave
|
||||
Title Bar|Search|Search (Tell Me)|tools.search
|
||||
Home|Clipboard|Paste|edit.paste
|
||||
Home|Clipboard|Paste Keep Text Only|edit.pasteText
|
||||
Home|Clipboard|Paste Merge Formatting|edit.pasteMerge
|
||||
@@ -258,7 +255,6 @@ References|Index|Insert Index|references.index
|
||||
References|Index|Update Index|references.updateIndex
|
||||
References|Table of Authorities|Mark Citation|references.markCitation
|
||||
References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities
|
||||
References|Table of Authorities|Update Table|references.updateAuthorities
|
||||
References|Fields|Update Fields|references.updateFields
|
||||
Mailings|Create|Envelopes|mailings.envelopes
|
||||
Mailings|Create|Labels|mailings.labels
|
||||
@@ -358,7 +354,6 @@ Table Design|Borders|Borders|table.borders
|
||||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
Table Layout|Table|Select Table|table.selectTable
|
||||
Table Layout|Table|Select Row|table.selectRow
|
||||
Table Layout|Table|Select Column|table.selectColumn
|
||||
Table Layout|Table|Select Cell|table.selectCell
|
||||
Table Layout|Table|View Gridlines|table.viewGridlines
|
||||
Table Layout|Table|Properties|table.properties
|
||||
|
||||
@@ -114,9 +114,8 @@ impl Checker<'_> {
|
||||
let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue };
|
||||
let pos = at(path, off);
|
||||
let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())});
|
||||
// Ink: every stroke is its own object, so asking about each would bury the report. A
|
||||
// hidden drawing isn't shown, so nobody reads it.
|
||||
if o.is_decorative() || o.ink().is_some() || o.frame().is_some_and(|(_, _, f)| f.hidden) {
|
||||
// Ink: every stroke is its own object, so asking about each would bury the report.
|
||||
if o.is_decorative() || o.ink().is_some() {
|
||||
continue;
|
||||
}
|
||||
let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty());
|
||||
|
||||
@@ -49,9 +49,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
|
||||
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
|
||||
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
|
||||
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.params(r#"{"unit": "word|sentence|paragraph", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
|
||||
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
|
||||
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
|
||||
let to_end = p::bool(v, "end").unwrap_or(false);
|
||||
@@ -291,15 +291,12 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
|
||||
/// selection bar (the left margin) selects a line; triple-click there the whole document.
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unit {
|
||||
Word,
|
||||
Sentence,
|
||||
Line,
|
||||
Paragraph,
|
||||
Document,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
@@ -307,36 +304,26 @@ impl Unit {
|
||||
match s {
|
||||
"word" => Some(Unit::Word),
|
||||
"sentence" => Some(Unit::Sentence),
|
||||
"line" => Some(Unit::Line),
|
||||
"paragraph" => Some(Unit::Paragraph),
|
||||
"document" => Some(Unit::Document),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The word, sentence, line, paragraph or story around `at`, as (start, end). A paragraph
|
||||
/// includes its mark (it ends at the start of the next paragraph in the same container, when
|
||||
/// there is one), and so does its last line.
|
||||
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
/// The word, sentence or paragraph around `at`, as (start, end). A paragraph includes its mark
|
||||
/// (it ends at the start of the next paragraph in the same container, when there is one).
|
||||
pub fn unit_range(s: &Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
|
||||
let len = p.len();
|
||||
let mark_end = |s: &Session| match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: len, ..at.clone() },
|
||||
};
|
||||
let (a, b) = match unit {
|
||||
Unit::Word => p.word_at(at.off),
|
||||
Unit::Sentence => p.sentence_at(at.off),
|
||||
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
|
||||
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
|
||||
Unit::Line => {
|
||||
let hint = s.page_hint;
|
||||
return match s.layout().line_span(at, hint) {
|
||||
Some((a, _, true)) => (a, mark_end(s)),
|
||||
Some((a, b, false)) => (a, b),
|
||||
None => (at.clone(), at.clone()),
|
||||
Unit::Paragraph => {
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: p.len(), ..at.clone() },
|
||||
};
|
||||
return (start, end);
|
||||
}
|
||||
};
|
||||
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
|
||||
@@ -363,23 +350,12 @@ fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
select_unit(s, Unit::Paragraph)
|
||||
}
|
||||
|
||||
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
|
||||
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
|
||||
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pos) = v.get("pos").and_then(parse_pos) {
|
||||
s.sel = Selection::caret(s.doc.clamp(&pos));
|
||||
}
|
||||
select_unit(s, Unit::Line)
|
||||
}
|
||||
|
||||
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
|
||||
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
|
||||
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
|
||||
/// anchors at its end, as in Word.
|
||||
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let unit = p::str(v, "unit")
|
||||
.and_then(Unit::parse)
|
||||
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".into()))?;
|
||||
let unit = p::str(v, "unit").and_then(Unit::parse).ok_or_else(|| CmdError::Params("`unit` must be word, sentence or paragraph".into()))?;
|
||||
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
|
||||
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
|
||||
let (start, end) = if start <= end { (start, end) } else { (end, start) };
|
||||
@@ -388,18 +364,20 @@ fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let (us, ue) = unit_range(s, &at, unit);
|
||||
s.sel = if unit == Unit::Document {
|
||||
Selection { anchor: us, focus: ue }
|
||||
} else if at >= start {
|
||||
Selection { focus: ue.max(end.clone()), anchor: start }
|
||||
} else {
|
||||
Selection { focus: us.min(start), anchor: end }
|
||||
};
|
||||
s.sel = if at >= start { Selection { focus: ue.max(end.clone()), anchor: start } } else { Selection { focus: us.min(start), anchor: end } };
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let l = s.layout();
|
||||
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
|
||||
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let needle = p::req_str(v, "text")?;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! of authorities, footnote options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, heading_letter, sort_key};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
|
||||
@@ -298,8 +297,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::<Vec<_>>()))
|
||||
})?;
|
||||
s.bib_style = st.name.to_string();
|
||||
// Recorded in the document too, so a save keeps it.
|
||||
s.doc.settings.bib_style = Some(st.name.to_string());
|
||||
update_citations(s)?;
|
||||
Ok(json!({"style": s.bib_style}))
|
||||
})
|
||||
@@ -337,10 +334,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
generated_list(s, "TOA", "Table of Authorities")?;
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| {
|
||||
update_generated(s)?;
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
|
||||
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
|
||||
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
|
||||
@@ -564,7 +557,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let src = s.doc.sources.iter().find(|x| x.tag == tag).cloned().ok_or_else(|| CmdError::Params(format!("no source `{tag}`")))?;
|
||||
let pages = p::str(v, "pages").unwrap_or("").to_string();
|
||||
let st = style(&s.bib_style);
|
||||
let n = number_of(&s.doc, &bib_order(&s.doc, st, super::default_lang(s)), &tag);
|
||||
let n = number_of(&s.doc, &bib_order(&s.doc, st), &tag);
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
|
||||
@@ -612,7 +605,7 @@ fn author_key(src: &Source) -> String {
|
||||
|
||||
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
|
||||
/// nothing is cited), sorted or in order of first citation as the style asks.
|
||||
fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) -> Vec<usize> {
|
||||
fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
|
||||
let cited = cited_tags(doc);
|
||||
let mut idx: Vec<usize> = if cited.is_empty() {
|
||||
(0..doc.sources.len()).collect()
|
||||
@@ -622,10 +615,8 @@ fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) -> Vec<usize>
|
||||
let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect();
|
||||
doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect()
|
||||
};
|
||||
// Sorted in the document's language (Polish authors `Łukasiewicz` before `Mazur`).
|
||||
let key = |i: &usize, by_title: bool| {
|
||||
sort_key(&doc.sources.get(*i).map(|x| if by_title { x.title.clone() } else { author_key(x) }).unwrap_or_default(), lang.as_deref())
|
||||
};
|
||||
let key =
|
||||
|i: &usize, by_title: bool| doc.sources.get(*i).map(|x| if by_title { x.title.to_lowercase() } else { author_key(x) }).unwrap_or_default();
|
||||
match st.order {
|
||||
Order::Author => idx.sort_by_cached_key(|i| key(i, false)),
|
||||
Order::Title => idx.sort_by_cached_key(|i| key(i, true)),
|
||||
@@ -648,7 +639,7 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// Refresh the results of the CITATION fields in the body.
|
||||
fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
let st = style(&s.bib_style);
|
||||
let order = bib_order(&s.doc, st, super::default_lang(s));
|
||||
let order = bib_order(&s.doc, st);
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
let updates: Vec<(usize, String)> = p
|
||||
@@ -766,7 +757,7 @@ fn page_of(s: &mut Session, pos: &Pos) -> u32 {
|
||||
fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
if kind.starts_with("BIBLIOGRAPHY") {
|
||||
let st = style(&s.bib_style);
|
||||
return bib_order(&s.doc, st, super::default_lang(s))
|
||||
return bib_order(&s.doc, st)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered())))
|
||||
@@ -827,22 +818,21 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
|
||||
}
|
||||
// Index / TOA: group by entry, sort, list pages.
|
||||
let lang = super::default_lang(s);
|
||||
let mut grouped: std::collections::BTreeMap<(SortKey, String), Vec<u32>> = Default::default();
|
||||
let mut grouped: std::collections::BTreeMap<String, Vec<u32>> = Default::default();
|
||||
for (e, p) in with_pages {
|
||||
let pages = grouped.entry((sort_key(&e, lang.as_deref()), e)).or_default();
|
||||
let pages = grouped.entry(e).or_default();
|
||||
if !pages.contains(&p) {
|
||||
pages.push(p);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut letter = None;
|
||||
for ((_, e), pages) in grouped {
|
||||
let first = heading_letter(&e, lang.as_deref());
|
||||
for (e, pages) in grouped {
|
||||
let first = e.chars().next().map(|c| c.to_ascii_uppercase());
|
||||
if kind.starts_with("INDEX") && first != letter {
|
||||
letter = first.clone();
|
||||
letter = first;
|
||||
if let Some(l) = first {
|
||||
out.push((l, false));
|
||||
out.push((l.to_string(), false));
|
||||
}
|
||||
}
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
@@ -982,71 +972,6 @@ mod tests {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bibliography_round_trip_docx() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "Studios matter "})).unwrap();
|
||||
s.run(
|
||||
"references.citation",
|
||||
&json!({
|
||||
"source": {
|
||||
"author": "Rivera, Alex; Chen, Mei",
|
||||
"title": "Shared Spaces",
|
||||
"year": "2021",
|
||||
"publisher": "Harbor Books",
|
||||
"city": "Portland",
|
||||
"editor": "Johnson, Robert",
|
||||
"edition": "2nd",
|
||||
"issue": "4",
|
||||
"month": "10",
|
||||
"day": "15",
|
||||
"accessed": "2024-05-15",
|
||||
"doi": "10.1000/182"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.bibliography", &json!({})).unwrap();
|
||||
|
||||
// Serialize to DOCX and back — sources must survive.
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let doc = wordcraft_docx::read(&bytes).expect("docx read");
|
||||
assert_eq!(doc.sources.len(), 1, "round-tripped sources: {:?}", doc.sources);
|
||||
assert_eq!(doc.sources[0].author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(doc.sources[0].title, "Shared Spaces");
|
||||
assert_eq!(doc.sources[0].editor, "Johnson, Robert");
|
||||
assert_eq!(doc.sources[0].edition, "2nd");
|
||||
assert_eq!(doc.sources[0].issue, "4");
|
||||
assert_eq!(doc.sources[0].month, "10");
|
||||
assert_eq!(doc.sources[0].day, "15");
|
||||
assert_eq!(doc.sources[0].accessed, "2024-05-15");
|
||||
assert_eq!(doc.sources[0].doi, "10.1000/182");
|
||||
|
||||
// Round-tripped document in a new session works with citation styles.
|
||||
let mut s2 = Session::new(doc);
|
||||
s2.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
|
||||
let t = s2.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains("(Rivera and Chen, 2021)"), "{t}");
|
||||
assert!(t.contains("Rivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books."), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_style_survives_docx_save_and_reopen() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap();
|
||||
s.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let doc = wordcraft_docx::read(&bytes).expect("docx read");
|
||||
assert_eq!(doc.settings.bib_style.as_deref(), Some("Harvard (Anglia)"));
|
||||
assert_eq!(Session::new(doc.clone()).bib_style, "Harvard (Anglia)");
|
||||
let mut other = Session::new(wordcraft_doc::Document::new());
|
||||
other.set_document(doc);
|
||||
assert_eq!(other.bib_style, "Harvard (Anglia)");
|
||||
// A document without a recorded style is APA.
|
||||
assert_eq!(Session::new(wordcraft_doc::Document::new()).bib_style, "APA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_figures() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nPresses are heavy.\nMore kilns."));
|
||||
@@ -1074,24 +999,6 @@ mod tests {
|
||||
assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s));
|
||||
}
|
||||
|
||||
/// #661: References › Table of Authorities › Update Table picks up citations marked after the table.
|
||||
#[test]
|
||||
fn table_of_authorities_updates() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed."));
|
||||
s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap();
|
||||
s.run("references.markCitation", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfAuthorities", &json!({})).unwrap();
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}");
|
||||
s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap();
|
||||
s.run("references.markCitation", &json!({})).unwrap();
|
||||
s.run("references.updateAuthorities", &json!({})).unwrap();
|
||||
let after = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}");
|
||||
}
|
||||
|
||||
/// Field instructions and results in the body, in order.
|
||||
fn fields(s: &Session) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
|
||||
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
|
||||
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
|
||||
//! Whole table cells that aren't one run of the text (part of a column, `CellRange`) are a block
|
||||
//! of their paragraphs: edits and formatting go cell by cell; table commands take the cells.
|
||||
//! Extending the selection from one cell into another (Shift+arrows, Shift+click, a drag) makes
|
||||
//! such cells, as in Word (`table::extend_selection`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
@@ -98,7 +94,7 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
|
||||
let l = s.layout();
|
||||
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments });
|
||||
s.pending = None;
|
||||
}
|
||||
|
||||
@@ -120,11 +116,6 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
}
|
||||
// Placing the caret ends a block even where its corners are (a selected empty cell's are
|
||||
// the same place).
|
||||
if matches!(id, "caret.set" | "select.collapse") {
|
||||
s.column = None;
|
||||
}
|
||||
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
|
||||
let mut params = v.clone();
|
||||
match params.as_object_mut() {
|
||||
@@ -141,25 +132,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
|
||||
s.column_mode = false;
|
||||
}
|
||||
// Extending the selection (Shift+arrows, Shift+click, a drag) goes on from where it really
|
||||
// runs, then takes whole cells or rows the way Word does around tables.
|
||||
if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) {
|
||||
let shown = s.sel.clone();
|
||||
s.sel = s.sel_ends().clone();
|
||||
if let Err(e) = run(s, v) {
|
||||
s.sel = shown;
|
||||
return Err(e);
|
||||
}
|
||||
let to = s.sel.focus.clone();
|
||||
super::table::extend_selection(s, to);
|
||||
return super::sel_result(s);
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
let cells = s.cell_range().is_some();
|
||||
match id {
|
||||
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
|
||||
// from it without clearing it, as in Word.
|
||||
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
@@ -172,8 +146,7 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
}
|
||||
"format.changeCase" | "format.clear" | "format.resetChar" => per_row(s, run, v, &segs),
|
||||
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
|
||||
// Pasting formatting (the format painter) leaves the text alone.
|
||||
_ if mutates && (id.starts_with("text.") || (id.starts_with("edit.paste") && id != "edit.pasteFormat")) => {
|
||||
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
|
||||
delete(s, &segs)?;
|
||||
run(s, v)
|
||||
}
|
||||
@@ -211,7 +184,7 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
|
||||
}
|
||||
s.clipboard_text = rows.join("\n");
|
||||
s.clipboard = Some(Fragment { blocks, parts });
|
||||
Ok(super::edit::copy_result(s))
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
|
||||
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
|
||||
|
||||
@@ -460,20 +460,20 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?;
|
||||
let at = place(s, &layout, obj, page, (fx, fy), first[1])?;
|
||||
// Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step).
|
||||
let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None };
|
||||
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape}))
|
||||
}
|
||||
|
||||
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
|
||||
/// under `anchor` (positioned from its column and top), or, where no paragraph starts on the
|
||||
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
|
||||
/// page, to the nearest line and positioned on the page. Returns where its character went.
|
||||
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor: (f32, f32)) -> Result<Pos, CmdError> {
|
||||
let (at, on_page) = match super::objects::anchor_paragraph(&s.doc, layout, page, anchor, StoryRef::Body) {
|
||||
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
|
||||
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) {
|
||||
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
|
||||
None => (
|
||||
super::objects::nearest_line(layout, page, anchor.1, StoryRef::Body)
|
||||
super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body)
|
||||
.ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?,
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -7,26 +7,6 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Undo several steps at once: the Undo button's list (#701). `count` is validated here and
|
||||
/// clamped to the undo depth, so hostile numbers never reach the stack.
|
||||
fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let n = p::u64(v, "count").ok_or_else(|| CmdError::Params("`count` (whole number, 1 or more) is required".into()))?;
|
||||
if n == 0 {
|
||||
return Err(CmdError::Params("`count` must be 1 or more".into()));
|
||||
}
|
||||
let n = usize::try_from(n).unwrap_or(usize::MAX).min(s.undo_depth());
|
||||
if s.step_lifts_protection(false) {
|
||||
return Err(CmdError::Disabled("edit.undoTo: use Stop Protection to lift the document's protection".into()));
|
||||
}
|
||||
let undone = s.undo_steps(n);
|
||||
let mut r = sel_result(s)?;
|
||||
if let Some(m) = r.as_object_mut() {
|
||||
m.insert("undone".into(), json!(undone));
|
||||
m.insert("depth".into(), json!(s.undo_depth()));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
@@ -42,14 +22,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.key("Mod+Z")
|
||||
.pure(),
|
||||
CommandSpec::new("edit.undoTo", "Undo Actions", "Quick Access Toolbar", undo_to)
|
||||
.params(r#"{"count": n (1 = the newest step, like edit.undo; n undoes the newest n steps at once; at most the undo depth)}"#)
|
||||
.when(|s| if s.can_undo() { None } else { Some("there is nothing to undo") })
|
||||
.pure(),
|
||||
CommandSpec::new("edit.undoHistory", "Undo History", "Quick Access Toolbar", |s, _| {
|
||||
Ok(json!({"undo": s.undo_history(), "depth": s.undo_depth(), "redo": s.redo_label()}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
|
||||
if s.step_lifts_protection(true) {
|
||||
return Err(CmdError::Disabled("edit.redo: use Stop Protection to lift the document's protection".into()));
|
||||
@@ -61,12 +33,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
|
||||
.params(r#"{"text"?: string}"#)
|
||||
.key("Mod+Shift+Alt+V"),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge)
|
||||
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
|
||||
s.view.clipboard_pane = on;
|
||||
@@ -114,25 +85,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
// Ctrl+H, Home › Replace: the Find and Replace dialog on its Replace tab (#712). Opening
|
||||
// it changes nothing, so it is its own command and never marks the document edited.
|
||||
CommandSpec::new("edit.findReplace", "Find and Replace", "Home › Editing", |s, v| {
|
||||
let tab = match p::str(v, "tab").unwrap_or("replace") {
|
||||
t @ ("find" | "replace" | "goto") => t,
|
||||
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
|
||||
};
|
||||
s.ui_requests.push(json!({"open": tab}));
|
||||
Ok(json!({"open": tab}))
|
||||
})
|
||||
.key("Mod+H")
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
|
||||
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
|
||||
@@ -159,20 +117,10 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(copy_result(s))
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when
|
||||
/// the copy holds any (#816), for the host to put on the system clipboard too.
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
_ => String::new(),
|
||||
};
|
||||
if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) }
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -185,18 +133,14 @@ fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Paste keeps the source formatting: our own copy while the system clipboard still holds it,
|
||||
/// else another program's HTML or RTF (#687), else the system clipboard's text.
|
||||
fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let frag = super::paste::paste_fragment(s, v)?;
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Merge Formatting: the pasted text takes the destination's formatting, keeping its emphasis.
|
||||
fn paste_merge(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut frag = super::paste::paste_fragment(s, v)?;
|
||||
super::paste::merge_formatting(s, &mut frag);
|
||||
// An explicit text param (from the system clipboard) wins unless it matches our own copy.
|
||||
let frag = match (p::str(v, "text"), &s.clipboard) {
|
||||
(Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(),
|
||||
(Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")),
|
||||
(None, Some(f)) => f.clone(),
|
||||
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
};
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -340,9 +284,6 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(t) => t.to_string(),
|
||||
None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(),
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Err(CmdError::Failed("the clipboard is empty".into()));
|
||||
}
|
||||
super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -544,10 +485,8 @@ fn read_opts(s: &mut Session, v: &Value) {
|
||||
fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
read_opts(s, v);
|
||||
if p::str(v, "text").is_none() {
|
||||
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
|
||||
// Find and Replace dialog is Advanced Find.
|
||||
s.view.nav_pane = true;
|
||||
s.ui_requests.push(json!({"focus": "navSearch"}));
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
@@ -658,13 +597,7 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Relative pages, Go To's "+2" / "-1": from the page the caret is on.
|
||||
let by = v.get("pageBy").and_then(Value::as_i64);
|
||||
let rel = by.map(|by| {
|
||||
let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64;
|
||||
(here + 1).saturating_add(by).max(1) as u64
|
||||
});
|
||||
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
|
||||
if let Some(pg) = p::u64(v, "page") {
|
||||
let l = s.layout();
|
||||
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
|
||||
let target = l.pages.get(idx).and_then(|page| {
|
||||
|
||||
@@ -7,11 +7,6 @@ use super::{pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Longest user name kept, in characters; longer names are cut, not refused.
|
||||
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
|
||||
pub const FORMAT_WORD_2003: &str = "wordml2003";
|
||||
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
|
||||
pub const FORMAT_FLAT_OPC: &str = "flatopc";
|
||||
|
||||
pub const MAX_AUTHOR_CHARS: usize = 255;
|
||||
|
||||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||||
@@ -40,9 +35,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S / Shift+F12")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
@@ -90,28 +85,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
|
||||
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
|
||||
.pure(),
|
||||
// Trust Center › Privacy Options: the document's `w:removePersonalInformation` and
|
||||
// `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062).
|
||||
// Changing them is a document change (undoable, refused on a protected document); reading
|
||||
// them is `file.privacy`, which changes nothing.
|
||||
CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| {
|
||||
let personal = p::bool(v, "removePersonalInfo");
|
||||
let date_time = p::bool(v, "removeDateTime");
|
||||
if personal.is_none() && date_time.is_none() {
|
||||
return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into()));
|
||||
}
|
||||
if let Some(on) = personal {
|
||||
s.doc.settings.remove_personal_info = on;
|
||||
}
|
||||
if let Some(on) = date_time {
|
||||
s.doc.settings.remove_date_time = on;
|
||||
}
|
||||
Ok(privacy(s))
|
||||
})
|
||||
.params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#),
|
||||
CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s)))
|
||||
.params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
|
||||
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
|
||||
let story = super::story_param(s, v);
|
||||
@@ -140,26 +113,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them.
|
||||
fn privacy(s: &Session) -> Value {
|
||||
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
|
||||
}
|
||||
|
||||
fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let doc = match p::str(v, "template").unwrap_or("blank") {
|
||||
"sample" => crate::sample::sample_document(),
|
||||
"letter" => crate::sample::letter(),
|
||||
"resume" => crate::sample::resume(),
|
||||
"report" => crate::sample::report(),
|
||||
// The Normal template: the region's paper (#838), written in the editing language (the
|
||||
// templates' text is English).
|
||||
_ => {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = &s.editing_lang {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
_ => Document::new(),
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
@@ -172,19 +132,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
};
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = &s.editing_lang
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
s.set_document(doc);
|
||||
s.path = Some(path.into());
|
||||
// Like Word, a document opened with a password is saved with it again.
|
||||
@@ -231,22 +184,11 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
s.password = Some(crate::io::Password::new(pw));
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let mut opts = export_options(s, v);
|
||||
match v.get("format").filter(|f| !f.is_null()) {
|
||||
None => {
|
||||
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
|
||||
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
|
||||
}
|
||||
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
|
||||
Some(_) => {
|
||||
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
|
||||
}
|
||||
}
|
||||
s.stamp_save();
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
crate::io::save_path_with(&path, &s.doc, password, &opts).map_err(CmdError::Failed)?;
|
||||
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
@@ -254,27 +196,6 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_word_2003_file(path: &std::path::Path) -> bool {
|
||||
use std::io::Read;
|
||||
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::new();
|
||||
// The root element and its namespaces come first.
|
||||
if f.take(64 * 1024).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn is_word_2003_file(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "path").is_none() {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
@@ -287,7 +208,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
|
||||
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
||||
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -296,13 +217,10 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
|
||||
let l = s.export_layout_with(export_options(s, v).markup);
|
||||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||||
// A page image is the printed page: headers and footers at full strength. Dimming them is
|
||||
// the on-screen cue while editing the body, not part of the page.
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.dim_header = false;
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
|
||||
let png = img.to_png();
|
||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||||
//!
|
||||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||||
//! embeds in PDFs like any other. Where it is kept between runs is [`FontDb::load_user_fonts`]'s
|
||||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_fonts::{FontDb, UserFont};
|
||||
|
||||
use crate::cmd::insert::base64_decode;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("font.add", "Add Font…", "Home › Font", add)
|
||||
.params(r#"{"path"?: string, "data"?: base64, "name"?: string (file name for "data")} — a TTF, OTF, TTC, WOFF or WOFF2 file; none: pick a file"#)
|
||||
.pure(),
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
]
|
||||
}
|
||||
|
||||
fn font_json(f: &UserFont) -> Value {
|
||||
json!({
|
||||
"file": f.file,
|
||||
"families": f.families(),
|
||||
"faces": f.faces.iter().map(|(family, style)| json!({"family": family, "style": style})).collect::<Vec<_>>(),
|
||||
"path": f.path.as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a font file named by the user, refusing one larger than the cap before reading it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
// Through `io`, which keeps automation inside its read root (#769).
|
||||
crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed)
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "url").is_some() {
|
||||
return Err(CmdError::Failed(
|
||||
"adding a font from a link isn't available yet: download the font file and add it with {\"path\"} or {\"data\"}".into(),
|
||||
));
|
||||
}
|
||||
let (name, bytes) = if let Some(d) = p::str(v, "data") {
|
||||
// Base64 is a third larger than the file: refuse before decoding an oversized one.
|
||||
if d.len() / 4 * 3 > wordcraft_fonts::MAX_FONT_FILE_BYTES + 3 {
|
||||
return Err(CmdError::Failed(format!("the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
|
||||
}
|
||||
let bytes = base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?;
|
||||
(p::str(v, "name").unwrap_or("font").to_string(), bytes)
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
(path.to_string(), read_font_file(path)?)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
|
||||
}
|
||||
} else {
|
||||
// From the ribbon: the UI shows a file picker and runs this again with the file.
|
||||
s.ui_requests.push(json!({"open": "addFont"}));
|
||||
return Ok(json!({"pending": true}));
|
||||
};
|
||||
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
// Text already set in the family (shown in a fallback until now) takes the new font.
|
||||
s.relayout();
|
||||
let families = r.font.families().join(", ");
|
||||
s.status = match &r.warning {
|
||||
Some(w) => format!("Added {families} ({w})"),
|
||||
None => format!("Added {families}"),
|
||||
};
|
||||
let mut out = font_json(&r.font);
|
||||
if let Some(w) = r.warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||||
let db = FontDb::global();
|
||||
let mut out = json!({
|
||||
"fonts": db.user_fonts().iter().map(font_json).collect::<Vec<_>>(),
|
||||
"folder": db.user_font_dir().map(|p| p.to_string_lossy().to_string()),
|
||||
});
|
||||
if p::bool(v, "all") == Some(true) {
|
||||
out["families"] = json!(db.families());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?;
|
||||
let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
s.relayout();
|
||||
s.status = format!("Removed {}", family.trim());
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -15,14 +15,15 @@ pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
|
||||
// The button formats both plain and complex-script text explicitly.
|
||||
// Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs`
|
||||
// follows `bold`.
|
||||
toggle(
|
||||
s,
|
||||
v,
|
||||
|r| r.bold,
|
||||
|c, on| {
|
||||
c.bold = Some(on);
|
||||
c.bold_cs = Some(on);
|
||||
c.bold_cs = None;
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -35,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|r| r.italic,
|
||||
|c, on| {
|
||||
c.italic = Some(on);
|
||||
c.italic_cs = Some(on);
|
||||
c.italic_cs = None;
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -167,15 +168,13 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph:
|
||||
/// an empty one's mark takes the formatting.
|
||||
/// The non-empty rows of a column selection, if one is active.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
let segs = s.column_segments()?;
|
||||
let cells = s.cell_range().is_some();
|
||||
let rows: Vec<(Pos, Pos)> = segs
|
||||
.iter()
|
||||
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
|
||||
.filter(|(a, b)| (a != b || cells) && a.path == b.path && a.story == b.story)
|
||||
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
|
||||
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
|
||||
.collect();
|
||||
// An empty block (column mode just started) formats like a caret.
|
||||
@@ -188,10 +187,6 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
// An empty cell's paragraph only had its mark formatted: lay it out again.
|
||||
if a == b {
|
||||
s.doc.para_mut(a.story, &a.path)?.touch();
|
||||
}
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
@@ -222,10 +217,6 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
// An empty cell: its mark's formatting.
|
||||
if a == b {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), &p.mark));
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
|
||||
@@ -328,7 +319,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sz = (sz * 2.0).round() / 2.0;
|
||||
apply(s, &|c| {
|
||||
c.size = Some(sz);
|
||||
c.size_cs = Some(sz);
|
||||
c.size_cs = None;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,7 +338,7 @@ fn step_size(s: &mut Session, dir: i32) -> CmdResult {
|
||||
// Each run steps from its own size when the selection mixes sizes.
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
c.size_cs = None;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,13 +346,17 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
||||
let next = (current_size(s) + d).clamp(1.0, 1638.0);
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
c.size_cs = None;
|
||||
})
|
||||
}
|
||||
|
||||
fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "color")?;
|
||||
let tc = TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?;
|
||||
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
|
||||
TextColor::Auto
|
||||
} else {
|
||||
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
|
||||
};
|
||||
apply(s, &|x| x.color = Some(tc))
|
||||
}
|
||||
|
||||
@@ -573,12 +568,7 @@ fn char_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
// The font dialog and callers supplying one value format both scripts. Explicit
|
||||
// complex-script values still take precedence; importing a DOCX never uses this command.
|
||||
props.size_cs = props.size_cs.or(props.size);
|
||||
props.bold_cs = props.bold_cs.or(props.bold);
|
||||
props.italic_cs = props.italic_cs.or(props.italic);
|
||||
let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
apply(s, &|c| c.overlay(&props))
|
||||
}
|
||||
|
||||
@@ -612,9 +602,6 @@ pub fn state(s: &Session) -> Value {
|
||||
"highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))),
|
||||
"style": style,
|
||||
"styleName": style_name,
|
||||
// The proofing language (a BCP 47 tag; null when mixed or unset) and "Do not check".
|
||||
"lang": same(&|r| json!(r.lang)),
|
||||
"noProof": all.iter().all(|r| r.no_proof),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, heading_letter, sort_key};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
@@ -168,15 +167,14 @@ struct PageRef {
|
||||
|
||||
#[derive(Default)]
|
||||
struct Node {
|
||||
/// The entry's levels as written (the key is their sort keys).
|
||||
/// The entry's levels as written (the key is their lower-case form).
|
||||
levels: Vec<String>,
|
||||
pages: Vec<PageRef>,
|
||||
see: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every XE entry in the body with its page (or page range), grouped by entry and sorted in the
|
||||
/// order of language `lang` (Polish files `Łódź` between `L` and `M`).
|
||||
fn collect(s: &mut Session, lang: Option<&str>) -> BTreeMap<Vec<SortKey>, Node> {
|
||||
/// Every XE entry in the body with its page (or page range), grouped by entry.
|
||||
fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
||||
let mut marks: Vec<(FieldCode, Pos)> = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
@@ -199,7 +197,7 @@ fn collect(s: &mut Session, lang: Option<&str>) -> BTreeMap<Vec<SortKey>, Node>
|
||||
})
|
||||
};
|
||||
let starts = s.doc.bookmarks();
|
||||
let mut tree: BTreeMap<Vec<SortKey>, Node> = BTreeMap::new();
|
||||
let mut tree: BTreeMap<Vec<String>, Node> = BTreeMap::new();
|
||||
for (f, pos) in marks {
|
||||
let text = f.args.first().cloned().unwrap_or_default();
|
||||
let levels: Vec<String> = text.split(':').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(9).collect();
|
||||
@@ -209,10 +207,10 @@ fn collect(s: &mut Session, lang: Option<&str>) -> BTreeMap<Vec<SortKey>, Node>
|
||||
// Every level above gets a line of its own, even without pages.
|
||||
for k in 1..levels.len() {
|
||||
let head: Vec<String> = levels.iter().take(k).cloned().collect();
|
||||
tree.entry(head.iter().map(|x| sort_key(x, lang)).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
tree.entry(head.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
}
|
||||
let node =
|
||||
tree.entry(levels.iter().map(|x| sort_key(x, lang)).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() });
|
||||
tree.entry(levels.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() });
|
||||
if let Some(see) = f.arg('t') {
|
||||
if !node.see.iter().any(|x| x == see) {
|
||||
node.see.push(see.to_string());
|
||||
@@ -280,9 +278,7 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
||||
let sep = f.arg('e').map(str::to_string).unwrap_or_else(|| ", ".into());
|
||||
let run_in = f.has('r');
|
||||
let headings = f.switches.iter().find(|(c, _)| *c == 'h').map(|(_, a)| a.clone().unwrap_or_default());
|
||||
// Entries sort in the language of the index's paragraph (else the document's).
|
||||
let lang = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| super::para_lang(&s.doc, p)).or_else(|| super::default_lang(s));
|
||||
let tree = collect(s, lang.as_deref());
|
||||
let tree = collect(s);
|
||||
let mut lines: Vec<(usize, Line)> = Vec::new();
|
||||
let mut letter: Option<String> = None;
|
||||
let mut keys = tree.keys().peekable();
|
||||
@@ -290,7 +286,7 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
||||
let Some(node) = tree.get(key) else { continue };
|
||||
let Some(main) = node.levels.first() else { continue };
|
||||
if let Some(h) = &headings {
|
||||
let first = heading_letter(main, lang.as_deref());
|
||||
let first = main.chars().next().map(|c| c.to_uppercase().to_string());
|
||||
if first != letter {
|
||||
if letter.is_some() || !h.is_empty() {
|
||||
lines.push((0, vec![(if h.is_empty() { String::new() } else { first.clone().unwrap_or_default() }, CharProps::default())]));
|
||||
|
||||
@@ -183,11 +183,19 @@ pub fn base64_encode(b: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let bytes = if let Some(d) = p::str(v, "data") {
|
||||
base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
|
||||
}
|
||||
} else {
|
||||
s.ui_requests.push(json!({"open": "insertPicture"}));
|
||||
return sel_result(s);
|
||||
@@ -201,26 +209,20 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
let (pw, ph) =
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Fitted to the text width.
|
||||
// Natural size at 96 ppi, fitted to the text width.
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
w *= k;
|
||||
|
||||
@@ -320,8 +320,14 @@ fn extension(path: &str) -> Option<String> {
|
||||
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
Err(CmdError::Failed("paths aren't available on the web; pass `data`".into()))
|
||||
}
|
||||
|
||||
/// Field names and records from a table's rows: with `headers`, the first row names the fields
|
||||
|
||||
@@ -16,7 +16,6 @@ pub mod fields;
|
||||
pub mod file;
|
||||
pub mod find_pattern;
|
||||
pub mod fmt_revisions;
|
||||
pub mod font;
|
||||
pub mod format;
|
||||
pub mod index;
|
||||
pub mod ink_shape;
|
||||
@@ -39,8 +38,6 @@ pub mod smart_art;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
pub mod table_draw;
|
||||
pub mod table_mouse;
|
||||
pub mod table_resize;
|
||||
pub mod table_style;
|
||||
pub mod templates;
|
||||
pub mod text;
|
||||
@@ -63,7 +60,6 @@ pub fn registry() -> Registry {
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(font::specs());
|
||||
v.extend(phonetic::specs());
|
||||
v.extend(para::specs());
|
||||
v.extend(lists::specs());
|
||||
@@ -76,8 +72,6 @@ pub fn registry() -> Registry {
|
||||
v.extend(table::specs());
|
||||
v.extend(table_style::specs());
|
||||
v.extend(table_draw::specs());
|
||||
v.extend(table_resize::specs());
|
||||
v.extend(table_mouse::specs());
|
||||
v.extend(review::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(options::specs());
|
||||
@@ -132,19 +126,6 @@ pub fn sel_result(s: &Session) -> CmdResult {
|
||||
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)}))
|
||||
}
|
||||
|
||||
/// The paragraphs the selection covers: those between its ends (not the next one when it ends at
|
||||
/// that one's start, #418) or, for whole table cells that aren't one run of the text (part of a
|
||||
/// column), just the cells' own.
|
||||
pub(crate) fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
|
||||
if s.cell_range().is_some()
|
||||
&& let Some(segs) = s.column_segments()
|
||||
{
|
||||
return segs.iter().map(|(a, _)| a.path.clone()).collect();
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.para_paths_in(&a, &b)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
@@ -290,18 +271,7 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
first = false;
|
||||
let part = part.replace('\u{000B}', "\n");
|
||||
if !part.is_empty() {
|
||||
let starts_para = direction_unset(s, &at);
|
||||
at = s.doc.insert_text(&at, &part, &props)?;
|
||||
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
|
||||
// sets makes it a right-to-left paragraph, so the caret and text start at the right
|
||||
// margin. Paragraphs that already have letters, or a direction, keep theirs.
|
||||
if starts_para
|
||||
&& let Ok(p) = s.doc.para_mut(at.story, &at.path)
|
||||
&& wordcraft_doc::bidi::first_strong_is_rtl(&p.text)
|
||||
{
|
||||
p.props.bidi = Some(true);
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
s.sel = crate::Selection::caret(at);
|
||||
@@ -309,17 +279,6 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
|
||||
/// punctuation), with no direction set on it, its style chain or the document defaults?
|
||||
fn direction_unset(s: &Session, at: &Pos) -> bool {
|
||||
let Some(p) = s.doc.para_at(at) else { return false };
|
||||
if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() {
|
||||
return false;
|
||||
}
|
||||
let style = p.props.style.as_deref().unwrap_or("Normal");
|
||||
!s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some())
|
||||
}
|
||||
|
||||
/// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a
|
||||
/// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off.
|
||||
pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {
|
||||
@@ -435,24 +394,6 @@ pub fn story_param(s: &Session, v: &Value) -> StoryRef {
|
||||
v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story)
|
||||
}
|
||||
|
||||
/// The language (`w:lang`) of a paragraph's text: that of its first run with text (else of the
|
||||
/// paragraph mark), through styles and the document defaults. Sorting and number parsing follow it.
|
||||
pub fn para_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Option<String> {
|
||||
let run = p.runs.iter().find(|r| r.len > 0).map(|r| &r.props).unwrap_or(&p.mark);
|
||||
doc.styles.resolve_char(p.props.style.as_deref(), run).lang
|
||||
}
|
||||
|
||||
/// The document's default text language (`w:docDefaults`, the Normal style).
|
||||
pub fn doc_lang(doc: &wordcraft_doc::Document) -> Option<String> {
|
||||
doc.styles.resolve_char(None, &wordcraft_doc::CharProps::default()).lang
|
||||
}
|
||||
|
||||
/// The language of text that names none: the document's default, else the editing language (a
|
||||
/// Polish interface writes Polish), else none (English). As AutoCorrect decides it.
|
||||
pub fn default_lang(s: &Session) -> Option<String> {
|
||||
doc_lang(&s.doc).or_else(|| s.editing_lang.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod iso_tests {
|
||||
use super::iso_from_unix_secs;
|
||||
|
||||
@@ -203,7 +203,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let bytes = if let Some(d) = p::str(v, "data") {
|
||||
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Err(CmdError::Failed("paths aren't available on the web".into()));
|
||||
}
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
@@ -701,8 +709,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
|
||||
// Keep a corner of it on the page.
|
||||
let x = x.max(12.0 - w).min(pg.w - 12.0);
|
||||
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
|
||||
// The paragraph under the middle of its top edge: in the column it mostly lands in.
|
||||
let (anchor, on_page) = match anchor_paragraph(&s.doc, &layout, page, (x + w / 2.0, y), story) {
|
||||
let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) {
|
||||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||||
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
|
||||
};
|
||||
@@ -802,50 +809,22 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// The top-level paragraph of `story` under `(x, y)` on `page`: in the column under `x` (from
|
||||
/// the section's column geometry), the last one starting at or above `y` (else the first
|
||||
/// starting in that column; else, with no paragraph starting in that column, the last starting
|
||||
/// at or above `y` anywhere on the page, else the first). Body paragraphs come from the page's
|
||||
/// items; header/footer ones from its header and footer. The column matters (#884): layout
|
||||
/// places a floating object when it lays out its anchor, so text laid out before that (an
|
||||
/// earlier column) can't wrap around it. (Nor do lines continuing a paragraph from the column
|
||||
/// before: layout breaks them once, in the column the paragraph starts in.)
|
||||
pub(crate) fn anchor_paragraph(
|
||||
doc: &wordcraft_doc::Document,
|
||||
layout: &wordcraft_layout::DocLayout,
|
||||
page: usize,
|
||||
(x, y): (f32, f32),
|
||||
story: StoryRef,
|
||||
) -> Option<wordcraft_doc::Path> {
|
||||
/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above
|
||||
/// it (else the first starting on the page). Body paragraphs come from the page's items;
|
||||
/// header/footer ones from its header and footer.
|
||||
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<wordcraft_doc::Path> {
|
||||
let pg = layout.pages.get(page)?;
|
||||
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
|
||||
StoryRef::Body => Box::new(pg.items.iter()),
|
||||
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
|
||||
};
|
||||
// Top-level text on the page: (path, left, top, starts the paragraph).
|
||||
let pieces: Vec<(&wordcraft_doc::Path, f32, f32, bool)> = items
|
||||
let starts: Vec<(&wordcraft_doc::Path, f32)> = items
|
||||
.filter_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { story: s, path, l0, x, y: top, .. } if *s == story && path.depth() == 0 => {
|
||||
Some((path, *x, *top, *l0 == 0))
|
||||
}
|
||||
wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let starts: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3).map(|p| (p.0, p.2)).collect();
|
||||
// The page's columns (left, right), from its section's column geometry (headers and footers
|
||||
// span the text area); the column under `x` is the rightmost one starting at or left of it.
|
||||
let boxes = match story {
|
||||
StoryRef::Body => doc.sections().get(pg.section).map(|(_, sp)| sp.column_boxes()).unwrap_or_default(),
|
||||
StoryRef::Part(_) => Vec::new(),
|
||||
};
|
||||
let mut cols: Vec<(f32, f32)> =
|
||||
boxes.iter().map(|(cx, w)| (pg.body.x + cx, pg.body.x + cx + w)).filter(|c| c.0.is_finite() && c.1.is_finite()).collect();
|
||||
cols.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let col_of = |px: f32| cols.iter().rposition(|c| c.0 <= px + 0.5).unwrap_or(0);
|
||||
let col = col_of(x);
|
||||
let in_col: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3 && col_of(p.1) == col).map(|p| (p.0, p.2)).collect();
|
||||
let pick = |v: &[(&wordcraft_doc::Path, f32)]| v.iter().rev().find(|(_, top)| *top <= y + 0.5).or(v.first()).map(|(p, _)| (*p).clone());
|
||||
pick(&in_col).or_else(|| pick(&starts))
|
||||
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
|
||||
}
|
||||
|
||||
/// The start of the top-level line of `story` on `page` nearest to `y`.
|
||||
@@ -1371,8 +1350,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
||||
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
|
||||
_ => ("object", format!("Object {}", i + 1)),
|
||||
};
|
||||
let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden);
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden})
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
@@ -1839,120 +1817,4 @@ mod tests {
|
||||
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
|
||||
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None));
|
||||
}
|
||||
|
||||
/// The lines of `page` beside `r` vertically (top above its bottom, bottom below its top).
|
||||
fn lines_beside(s: &mut Session, page: usize, r: wordcraft_geom::Rect) -> Vec<(f32, f32, f32)> {
|
||||
let layout = s.layout();
|
||||
wordcraft_layout::hit::page_lines(&layout.pages[page], StoryRef::Body)
|
||||
.iter()
|
||||
.filter(|l| l.top < r.bottom() - 0.5 && l.bottom > r.y + 0.5)
|
||||
.map(|l| (l.top, l.left, l.right))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #884: a Square-wrapped shape dragged across a two-column page: the text in whichever
|
||||
/// column it lands in wraps around it there, and its old place closes up.
|
||||
#[test]
|
||||
fn dragged_square_shape_wraps_text_in_its_new_column() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
for i in 0..40 {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of a narrow column.")}))
|
||||
.unwrap();
|
||||
}
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"width": 80, "height": 60})).unwrap();
|
||||
let cols: Vec<(f32, f32)> = {
|
||||
let layout = s.layout();
|
||||
let mut v: Vec<(f32, f32)> =
|
||||
wordcraft_layout::hit::page_lines(&layout.pages[0], StoryRef::Body).iter().map(|l| (l.left, l.right)).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v.dedup_by(|a, b| (a.0 - b.0).abs() < 1.0);
|
||||
v
|
||||
};
|
||||
assert_eq!(cols.len(), 2, "two columns: {cols:?}");
|
||||
let mid = |c: (f32, f32)| (c.0 + c.1) / 2.0 - 40.0;
|
||||
let wrapped = |s: &mut Session, r: wordcraft_geom::Rect| {
|
||||
let beside = lines_beside(s, 0, r);
|
||||
assert!(!beside.is_empty(), "text beside {r:?}");
|
||||
for (top, left, right) in beside {
|
||||
assert!(right <= r.x + 0.5 || left >= r.right() - 0.5, "line at {top} ({left}..{right}) runs into {r:?}");
|
||||
}
|
||||
};
|
||||
let full = |s: &mut Session, r: wordcraft_geom::Rect, c: (f32, f32)| {
|
||||
for (top, left, right) in lines_beside(s, 0, r) {
|
||||
if left < c.1 && right > c.0 {
|
||||
assert!((left - c.0).abs() < 0.5 && (right - c.1).abs() < 0.5, "line at {top} ({left}..{right}) still kept clear in {c:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
// Into the second column, then back into the first, then again to the second, lower.
|
||||
for (col, y, from) in [(1, 300.0, None), (0, 200.0, Some(1)), (1, 400.0, Some(0))] {
|
||||
let old = object_area(&mut s).rect;
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": mid(cols[col]), "y": y})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!((o.page, o.rect.x, o.rect.y), (0, mid(cols[col]), y), "dropped where asked");
|
||||
wrapped(&mut s, o.rect);
|
||||
if let Some(c) = from {
|
||||
full(&mut s, old, cols[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two columns of `n` paragraphs.
|
||||
fn two_column_doc(n: usize) -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
for i in 0..n {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
let text = format!("Paragraph {i} has words enough to run over several lines of a narrow column, and then some more words. ");
|
||||
s.run("text.insert", &json!({"text": text.repeat(3)})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The page's columns (left, right) from its section's geometry.
|
||||
fn page_columns(s: &mut Session, page: usize) -> Vec<(f32, f32)> {
|
||||
let layout = s.layout();
|
||||
let pg = &layout.pages[page];
|
||||
let boxes = s.doc.sections()[pg.section].1.column_boxes();
|
||||
boxes.iter().map(|(x, w)| (pg.body.x + x, pg.body.x + x + w)).collect()
|
||||
}
|
||||
|
||||
/// #884 review: mirrored indents shift some paragraphs on even pages; that shift isn't taken
|
||||
/// for a column of its own, so the drop anchors to the paragraph under it, mirrored or not.
|
||||
#[test]
|
||||
fn mirrored_indents_make_no_extra_column() {
|
||||
let mut s = two_column_doc(40);
|
||||
for i in (1..40).step_by(2) {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||||
p.props.mirror_indents = Some(true);
|
||||
p.props.indent_right = Some(36.0);
|
||||
p.touch();
|
||||
}
|
||||
s.touch();
|
||||
assert!(s.layout().pages.len() > 1);
|
||||
let cols = page_columns(&mut s, 1);
|
||||
// On page 2, a plain paragraph starting in the first column below a mirrored one.
|
||||
let layout = s.layout();
|
||||
let starts: Vec<(usize, f32, f32)> = layout.pages[1]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { path, l0: 0, x, y, .. } if path.depth() == 0 && *x < cols[1].0 - 0.5 => Some((path.last(), *x, *y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(starts.iter().any(|p| p.1 > cols[0].0 + 1.0), "mirrored paragraphs shift right: {starts:?}");
|
||||
let &(plain, _, top) = starts.iter().skip(1).find(|p| p.0 % 2 == 0).unwrap();
|
||||
let mid = (cols[0].0 + cols[0].1) / 2.0;
|
||||
let got = anchor_paragraph(&s.doc, &layout, 1, (mid, top + 1.0), StoryRef::Body);
|
||||
assert_eq!(got, Some(wordcraft_doc::Path::top(plain)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas", "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -95,23 +95,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let units = match v.get("units") {
|
||||
None | Some(Value::Null) => None,
|
||||
// `auto` forgets the choice: lengths follow the region again (#838).
|
||||
Some(Value::String(a)) if a == "auto" => Some(None),
|
||||
Some(u) => Some(Some(
|
||||
Some(u) => Some(
|
||||
serde_json::from_value::<wordcraft_geom::Unit>(u.clone())
|
||||
.map_err(|_| CmdError::Params("`units` is one of inches, centimeters, millimeters, points, picas, auto".into()))?,
|
||||
)),
|
||||
};
|
||||
let region = match v.get("region") {
|
||||
None | Some(Value::Null) => None,
|
||||
// `auto` (or an empty tag) forgets the choice: the system's region again.
|
||||
Some(Value::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => Some(None),
|
||||
// Kept as the tag (normalised, short), so it is saved and read back as chosen.
|
||||
Some(Value::String(t)) => {
|
||||
let r = wordcraft_geom::Region::from_tag(t);
|
||||
Some(Some(if r.territory.is_empty() { r.language } else { format!("{}-{}", r.language, r.territory) }))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`region` must be a locale tag like pl-PL, or auto".into())),
|
||||
.map_err(|_| CmdError::Params("`units` is one of inches, centimeters, millimeters, points, picas".into()))?,
|
||||
),
|
||||
};
|
||||
let mut flag_values = Vec::new();
|
||||
for (k, f) in flags() {
|
||||
@@ -138,7 +125,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let setting = author.is_some()
|
||||
|| initials.is_some()
|
||||
|| units.is_some()
|
||||
|| region.is_some()
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
@@ -153,9 +139,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(u) = units {
|
||||
s.prefs.units = u;
|
||||
}
|
||||
if let Some(r) = region {
|
||||
s.prefs.region = r;
|
||||
}
|
||||
for (f, b) in flag_values {
|
||||
*f(&mut s.prefs) = b;
|
||||
}
|
||||
@@ -171,9 +154,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for w in &remove {
|
||||
wordcraft_proof::remove_word(w);
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() {
|
||||
s.persist_user_dictionary(&remove);
|
||||
}
|
||||
if setting {
|
||||
// Marks, hidden text and proofing change what is laid out.
|
||||
s.relayout();
|
||||
@@ -190,14 +170,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// The settings `file.options` reads and sets.
|
||||
pub fn current(s: &Session) -> Value {
|
||||
let pr = &s.prefs;
|
||||
let region = s.region();
|
||||
json!({
|
||||
"author": s.author,
|
||||
"initials": s.user_initials(),
|
||||
"units": s.units(),
|
||||
"unitsFromRegion": pr.units.is_none(),
|
||||
"region": {"language": region.language, "territory": region.territory, "paper": region.paper().0, "decimal": region.decimal().to_string()},
|
||||
"regionFromSystem": pr.region.is_none(),
|
||||
"units": pr.units,
|
||||
"typingReplacesSelection": pr.typing_replaces_selection,
|
||||
"insertKeyOvertype": pr.insert_key_overtype,
|
||||
"overtype": pr.overtype,
|
||||
|
||||
@@ -480,24 +480,14 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"oddPage" => SectionStart::OddPage,
|
||||
x => return Err(CmdError::Params(format!("unknown break `{x}`"))),
|
||||
};
|
||||
// A section break goes just before the selection and never replaces it, as in Word (#852:
|
||||
// selected text was deleted, so the first section lost its text).
|
||||
let old = s.sel.clone();
|
||||
let (at, end) = old.ordered();
|
||||
if at.story != wordcraft_doc::StoryRef::Body || at.path.depth() > 0 {
|
||||
if s.sel.focus.story != wordcraft_doc::StoryRef::Body || s.sel.focus.path.depth() > 0 {
|
||||
return Err(CmdError::Failed("section breaks go in the main text".into()));
|
||||
}
|
||||
// Section break: the paragraph before the caret ends a section with the current props; the
|
||||
// new section (after) takes them too and starts as requested.
|
||||
s.sel = Selection::caret(at.clone());
|
||||
let at = super::delete_selection(s)?;
|
||||
let cur = sect(s);
|
||||
let new = match s.doc.split_paragraph(&at) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
s.sel = old;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let new = s.doc.split_paragraph(&at)?;
|
||||
let ended = at.path.clone();
|
||||
let para = s.doc.para_mut(at.story, &ended)?;
|
||||
let mut props = cur;
|
||||
@@ -506,32 +496,11 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||
para.section = Some(Box::new(props.clone()));
|
||||
props.start = start;
|
||||
*s.doc.section_mut(next_block) = props;
|
||||
// The selection keeps its text, which now starts the new section.
|
||||
let first = Pos { off: 0, ..new };
|
||||
let last = shifted_by_split(&end, &at, &first);
|
||||
s.sel = if old.anchor <= old.focus { Selection { anchor: first, focus: last } } else { Selection { anchor: last, focus: first } };
|
||||
s.sel = Selection::caret(Pos { off: 0, ..new });
|
||||
let _ = Block::Para;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Where `p` lands after the top-level body paragraph at `at` was split there, the second half
|
||||
/// starting at `new`: later text in that paragraph moves into the new one, later blocks move down.
|
||||
fn shifted_by_split(p: &Pos, at: &Pos, new: &Pos) -> Pos {
|
||||
if p.story != at.story {
|
||||
return p.clone();
|
||||
}
|
||||
if p.path == at.path {
|
||||
return if p.off >= at.off { Pos { off: p.off.saturating_sub(at.off), ..new.clone() } } else { p.clone() };
|
||||
}
|
||||
let mut q = p.clone();
|
||||
if let (Some(first), Some(i)) = (q.path.0.first_mut(), at.path.0.first())
|
||||
&& *first > *i
|
||||
{
|
||||
*first = first.saturating_add(1);
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,56 +532,6 @@ mod tests {
|
||||
assert_eq!(sect(&s).text_direction, TextDirection::Down);
|
||||
}
|
||||
|
||||
/// #852: a continuous section break with text selected goes before the selection and keeps
|
||||
/// it, as in Word; it used to delete the text, so the first section came out empty. Saved and
|
||||
/// opened again, both sections' text lays out on page 1, the second below the first.
|
||||
#[test]
|
||||
fn section_break_keeps_the_selected_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "one");
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
let texts = |d: &wordcraft_doc::Document| -> Vec<String> {
|
||||
d.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().to_string()) } else { None }).collect()
|
||||
};
|
||||
assert_eq!(texts(&s.doc), ["", "one", "two"]);
|
||||
// A backward selection in the middle of a paragraph keeps its text and direction too.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("alpha beta\ngamma"));
|
||||
let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
|
||||
s.sel = Selection { anchor: p(10), focus: p(6) };
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "beta");
|
||||
assert!(s.sel.anchor > s.sel.focus);
|
||||
assert_eq!(texts(&s.doc), ["alpha ", "beta", "gamma"]);
|
||||
|
||||
// The issue's document: "one", a continuous break, "two" — both on page 1 after a round trip.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("caret.end", &json!({})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.sections().len(), 2);
|
||||
assert_eq!(back.last_section.start, SectionStart::Continuous);
|
||||
let mut s = Session::new(back);
|
||||
let l = s.layout();
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let mut lines: Vec<(String, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
crate::layout::Placed::Lines { path, y, .. } => {
|
||||
let Some(Block::Para(p)) = s.doc.body.get(path.last()).map(|b| &**b) else { return None };
|
||||
(!p.plain_text().is_empty()).then(|| (p.plain_text().to_string(), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let order: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(order, ["one", "two"]);
|
||||
}
|
||||
|
||||
/// More Columns: unequal widths with a line between, as one undo step.
|
||||
#[test]
|
||||
fn unequal_columns_with_a_line_between() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -79,11 +78,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("para.oneAndHalf", "1.5 Line Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.5))).key("Mod+5"),
|
||||
CommandSpec::new("para.spacing", "Paragraph Spacing", "Layout › Paragraph", |s, v| {
|
||||
let (b, a) = (p::f32(v, "before"), p::f32(v, "after"));
|
||||
let (b_auto, a_auto) = (p::bool(v, "beforeAuto"), p::bool(v, "afterAuto"));
|
||||
// A number replaces automatic spacing, as typing one in Word's Paragraph dialog does.
|
||||
let rp = cur(s);
|
||||
let b_auto = b_auto.or((b.is_some() && rp.before_auto).then_some(false));
|
||||
let a_auto = a_auto.or((a.is_some() && rp.after_auto).then_some(false));
|
||||
fmt(s, &|p| {
|
||||
if let Some(x) = b {
|
||||
p.space_before = Some(x.clamp(0.0, 1584.0));
|
||||
@@ -91,35 +85,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(x) = a {
|
||||
p.space_after = Some(x.clamp(0.0, 1584.0));
|
||||
}
|
||||
if let Some(on) = b_auto {
|
||||
p.space_before_auto = Some(on);
|
||||
}
|
||||
if let Some(on) = a_auto {
|
||||
p.space_after_auto = Some(on);
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"before"?: pt, "after"?: pt, "beforeAuto"?: bool, "afterAuto"?: bool (automatic, HTML-style spacing: 14 pt)}"#),
|
||||
.params(r#"{"before"?: pt, "after"?: pt}"#),
|
||||
CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let rp = cur(s);
|
||||
let has = rp.space_before > 0.0;
|
||||
fmt(s, &|p| {
|
||||
p.space_before = Some(if has { 0.0 } else { 12.0 });
|
||||
if rp.before_auto {
|
||||
p.space_before_auto = Some(false);
|
||||
}
|
||||
})
|
||||
let has = cur(s).space_before > 0.0;
|
||||
fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 }))
|
||||
})
|
||||
.key("Mod+0"),
|
||||
CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let rp = cur(s);
|
||||
let has = rp.space_after > 0.0;
|
||||
fmt(s, &|p| {
|
||||
p.space_after = Some(if has { 0.0 } else { 8.0 });
|
||||
if rp.after_auto {
|
||||
p.space_after_auto = Some(false);
|
||||
}
|
||||
})
|
||||
let has = cur(s).space_after > 0.0;
|
||||
fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 }))
|
||||
}),
|
||||
// Back to the paragraph style: direct indents, spacing, alignment… go; the style, list
|
||||
// membership and drop cap stay. ⌘Q quits on macOS, so Word for Mac uses ⌃Q.
|
||||
@@ -254,12 +230,8 @@ fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
}
|
||||
|
||||
pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
for path in super::selected_paths(s) {
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
f(&mut para.props);
|
||||
para.touch();
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_paragraphs(&a, &b, f)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -339,8 +311,8 @@ fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
@@ -361,8 +333,8 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
@@ -424,7 +396,7 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return super::format::apply(s, &|c| c.style = Some(id.clone()));
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
fmt(s, &|p| {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
p.style = Some(id.clone());
|
||||
// Applying a heading or Normal removes direct list numbering (except list styles).
|
||||
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) {
|
||||
@@ -461,10 +433,10 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
|
||||
let off = p::bool(v, "off").unwrap_or(false);
|
||||
let a = s.sel.ordered().0;
|
||||
let (a, b) = s.sel.ordered();
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> =
|
||||
super::selected_paths(s).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
s.doc.para_paths_in(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let same_kind = |n: &NumRef| {
|
||||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
@@ -830,42 +802,28 @@ fn sort(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Failed("select paragraphs in one place to sort".into()));
|
||||
}
|
||||
let (i0, i1) = (a.path.last(), b.path.last());
|
||||
let lang = s.doc.para(a.story, &a.path).and_then(|p| super::para_lang(&s.doc, p)).or_else(|| super::default_lang(s));
|
||||
let bl = s.doc.container_mut(a.story, &a.path)?;
|
||||
let Some(slice) = bl.get_mut(i0..=i1) else { return Err(CmdError::Failed("bad range".into())) };
|
||||
if slice.iter().any(|b| !matches!(**b, Block::Para(_))) {
|
||||
return Err(CmdError::Failed("sorting tables isn't supported here; use Table › Sort".into()));
|
||||
}
|
||||
let mut keyed: Vec<_> =
|
||||
slice.iter().map(|b| (natural_key(&b.as_para().map(|p| p.plain_text()).unwrap_or_default(), lang.as_deref()), b.clone())).collect();
|
||||
keyed.sort_by(|(x, _), (y, _)| {
|
||||
let o = natural_cmp(x, y);
|
||||
let key = |b: &std::sync::Arc<Block>| b.as_para().map(|p| p.plain_text().to_lowercase()).unwrap_or_default();
|
||||
slice.sort_by(|x, y| {
|
||||
let o = natural_cmp(&key(x), &key(y));
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
for (dst, (_, blk)) in slice.iter_mut().zip(keyed) {
|
||||
*dst = blk;
|
||||
}
|
||||
// Keep a section break on the last paragraph of the range where it was.
|
||||
s.sel = crate::Selection { anchor: Pos { off: 0, ..a }, focus: b };
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// A paragraph's sort key: the number it starts with, then its text in its language's order.
|
||||
fn natural_key(text: &str, lang: Option<&str>) -> (Option<f64>, SortKey) {
|
||||
let num = text.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
|
||||
(num, sort_key(text, lang))
|
||||
}
|
||||
|
||||
/// Paragraphs starting with a number come first, in numeric order ("item 2" < "item 10" within
|
||||
/// the same number, by text); the rest follow in collation order. A total order.
|
||||
fn natural_cmp(a: &(Option<f64>, SortKey), b: &(Option<f64>, SortKey)) -> std::cmp::Ordering {
|
||||
let num = match (a.0, b.0) {
|
||||
(Some(x), Some(y)) => x.total_cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
};
|
||||
num.then_with(|| a.1.cmp(&b.1))
|
||||
/// Compare strings with embedded numbers numerically ("item 2" < "item 10").
|
||||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let num = |s: &str| s.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
|
||||
match (num(a), num(b)) {
|
||||
(Some(x), Some(y)) if x != y => x.total_cmp(&y),
|
||||
_ => a.cmp(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -1188,29 +1146,3 @@ fn own_format(st: &Style) -> Value {
|
||||
pub fn touch_all(s: &mut Session) {
|
||||
s.dirty = true;
|
||||
}
|
||||
|
||||
/// Which Home › Paragraph list buttons show as on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListButtons {
|
||||
pub bullets: bool,
|
||||
pub numbering: bool,
|
||||
pub multilevel: bool,
|
||||
}
|
||||
|
||||
/// The list buttons for a paragraph with resolved `numbering` (the ribbon passes the paragraph
|
||||
/// props it already resolved). The paragraph's own level decides Bullets (a bulleted level) or
|
||||
/// Numbering (a numbered one); Multilevel is on too when the list numbers its levels together:
|
||||
/// some level's label shows another level's number (`1.1.`) or uses legal numbering.
|
||||
pub fn list_buttons(doc: &wordcraft_doc::Document, numbering: Option<NumRef>) -> ListButtons {
|
||||
let Some(n) = numbering.filter(|n| n.num != 0) else { return ListButtons::default() };
|
||||
let Some(level) = doc.numbering.level(n.num, n.level) else { return ListButtons::default() };
|
||||
let bullets = level.format == wordcraft_doc::section::NumFormat::Bullet;
|
||||
let joined = |i: u8| {
|
||||
doc.numbering.level(n.num, i).is_some_and(|l| {
|
||||
let own = u32::from(i) + 1;
|
||||
l.legal || l.text.split('%').skip(1).filter_map(|t| t.chars().next()?.to_digit(10)).any(|k| k != own)
|
||||
})
|
||||
};
|
||||
let multilevel = (0..9u8).any(joined);
|
||||
ListButtons { bullets, numbering: !bullets, multilevel }
|
||||
}
|
||||
|
||||
+11
-131
@@ -1,11 +1,9 @@
|
||||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, picture,
|
||||
//! unformatted), and the formatted content Paste and Merge Formatting take from other programs.
|
||||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
|
||||
//!
|
||||
//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
|
||||
//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
|
||||
//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or
|
||||
//! HTML source can also be pasted as that format. Paste keeps the source formatting of another
|
||||
//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).
|
||||
//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
|
||||
//! can also be pasted as that format.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -19,25 +17,18 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Largest clipboard payload Paste Special accepts, in bytes.
|
||||
pub const MAX_PASTE_BYTES: usize = 16 << 20;
|
||||
/// Largest clipboard picture Paste Special accepts (base64), in bytes.
|
||||
pub const MAX_PICTURE_BYTES: usize = 96 << 20;
|
||||
/// Nesting depth we follow when adjusting pasted tables.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The formats, in the order the dialog lists them: (id, label).
|
||||
pub const FORMATS: [(&str, &str); 5] = [
|
||||
("formatted", "Formatted Text"),
|
||||
("rtf", "Rich Text Format (RTF)"),
|
||||
("html", "HTML Format"),
|
||||
("picture", "Picture"),
|
||||
("text", "Unformatted Text"),
|
||||
];
|
||||
pub const FORMATS: [(&str, &str); 4] =
|
||||
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
|
||||
.key("Mod+Alt+V")
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#),
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,13 +57,12 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None,
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
|
||||
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
|
||||
"picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty),
|
||||
"text" => clip_text(s, v).and_then(non_empty),
|
||||
_ => None,
|
||||
}
|
||||
@@ -93,14 +83,11 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
|
||||
}
|
||||
}
|
||||
if p::str(v, "picture").is_some_and(|t| t.len() > MAX_PICTURE_BYTES) {
|
||||
return Err(CmdError::Params(format!("`picture` is larger than {} MB", MAX_PICTURE_BYTES >> 20)));
|
||||
}
|
||||
let ids = available(s, v);
|
||||
let Some(fmt) = p::str(v, "as") else {
|
||||
// No format chosen: list them and ask a front end (if any) to show the dialog.
|
||||
let mut req = json!({"open": "pasteSpecial", "formats": ids});
|
||||
for k in ["text", "html", "rtf", "picture"] {
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = p::str(v, k) {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
@@ -112,9 +99,8 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"formatted" | "keep" | "wordcraft" => "formatted",
|
||||
"rtf" => "rtf",
|
||||
"html" => "html",
|
||||
"picture" | "png" | "image" => "picture",
|
||||
"text" | "unformatted" | "plain" => "text",
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))),
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
|
||||
};
|
||||
let Some(src) = source(s, v, fmt) else {
|
||||
return Err(CmdError::Failed(if ids.is_empty() {
|
||||
@@ -130,13 +116,8 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
r["pastedAs"] = json!(fmt);
|
||||
return Ok(r);
|
||||
}
|
||||
"picture" => {
|
||||
let mut r = super::insert::picture(s, &json!({"data": src}))?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
return Ok(r);
|
||||
}
|
||||
"formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
|
||||
"html" => adopt(s, wordcraft_formats::html::import(html_fragment(&src))),
|
||||
"html" => adopt(s, wordcraft_formats::html::import(&src)),
|
||||
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
|
||||
};
|
||||
insert(s, frag)?;
|
||||
@@ -145,102 +126,6 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The HTML itself from a Windows "HTML Format" (CF_HTML) clipboard payload — a `Version:` header
|
||||
/// giving byte offsets — or the text unchanged. Hostile offsets fall back to the text after the
|
||||
/// header.
|
||||
pub fn html_fragment(html: &str) -> &str {
|
||||
if !html.starts_with("Version:") {
|
||||
return html;
|
||||
}
|
||||
let offset = |key: &str| -> Option<usize> {
|
||||
let at = html.find(key)?.checked_add(key.len())?;
|
||||
let digits: &str = html.get(at..)?.split(['\r', '\n']).next()?;
|
||||
digits.trim().parse().ok()
|
||||
};
|
||||
if let (Some(a), Some(b)) = (offset("StartFragment:"), offset("EndFragment:"))
|
||||
&& let Some(frag) = html.get(a..b)
|
||||
{
|
||||
return frag;
|
||||
}
|
||||
html.find('<').and_then(|i| html.get(i..)).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Whether an imported document has anything to paste: text, a table, or a picture it carries.
|
||||
fn has_content(d: &Document) -> bool {
|
||||
d.body.iter().any(|b| match &**b {
|
||||
Block::Para(p) => !p.text.trim().is_empty() || (!p.objects.is_empty() && !d.media.is_empty()),
|
||||
Block::Table(_) => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Another program's formatted copy, from the system clipboard's HTML, else its RTF (#687), as a
|
||||
/// fragment of this document. `None` when neither is given, either is larger than
|
||||
/// [`MAX_PASTE_BYTES`], or it holds nothing to paste (the caller pastes the text then).
|
||||
pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
|
||||
let fits = |k: &str| p::str(v, k).filter(|t| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES);
|
||||
let doc = fits("html").map(|html| wordcraft_formats::html::import(html_fragment(html)));
|
||||
let doc = match doc.filter(has_content) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let rtf = fits("rtf").filter(|t| looks_like_rtf(t))?;
|
||||
wordcraft_formats::rtf::import(rtf.as_bytes()).ok().filter(has_content)?
|
||||
}
|
||||
};
|
||||
Some(adopt(s, doc))
|
||||
}
|
||||
|
||||
/// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else
|
||||
/// another program's formatted copy (HTML, RTF), else the system clipboard's text.
|
||||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||||
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
|
||||
if let Some(f) = &s.clipboard
|
||||
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
|
||||
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
|
||||
{
|
||||
return Ok(f.clone());
|
||||
}
|
||||
if let Some(f) = rich_fragment(s, v) {
|
||||
return Ok(f);
|
||||
}
|
||||
match text {
|
||||
Some(t) if !t.is_empty() => Ok(Fragment::from_text(&t)),
|
||||
_ => Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Formatting (#687): the pasted text takes the formatting where it lands — the paragraph's
|
||||
/// and the caret's — keeping only its emphasis (bold, italic, underline, strikethrough).
|
||||
pub fn merge_formatting(s: &Session, frag: &mut Fragment) {
|
||||
let mut base = s.typing_props();
|
||||
base.ins = None;
|
||||
base.del = None;
|
||||
base.fmt_change = None;
|
||||
let (start, _) = s.sel.ordered();
|
||||
let para = s.doc.para_at(&start).map(|p| p.props.clone()).unwrap_or_default();
|
||||
for b in &mut frag.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
p.props = para.clone();
|
||||
for run in &mut p.runs {
|
||||
let src = std::mem::replace(&mut run.props, base.clone());
|
||||
if src.bold == Some(true) {
|
||||
run.props.bold = Some(true);
|
||||
}
|
||||
if src.italic == Some(true) {
|
||||
run.props.italic = Some(true);
|
||||
}
|
||||
if src.underline.is_some_and(|u| u != wordcraft_doc::props::Underline::None) {
|
||||
run.props.underline = src.underline;
|
||||
}
|
||||
if src.strike == Some(true) {
|
||||
run.props.strike = Some(true);
|
||||
}
|
||||
run.props.link = src.link;
|
||||
}
|
||||
p.touch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
|
||||
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
super::controls::prepare_edit(s)?;
|
||||
@@ -286,7 +171,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
}
|
||||
|
||||
/// Every paragraph in a block, tables included (bounded depth).
|
||||
pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
@@ -333,11 +218,6 @@ fn adopt(s: &mut Session, src: Document) -> Fragment {
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
// Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size).
|
||||
let max_w = crate::io::text_width(&s.doc);
|
||||
for b in &mut blocks {
|
||||
crate::io::fit_block_pictures(b, max_w);
|
||||
}
|
||||
for b in &mut blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
if let Some(n) = p.props.numbering.as_mut()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! References tab: table of contents, footnotes/endnotes, captions; field updates.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{InlineObject, NoteKind, OBJ};
|
||||
use wordcraft_doc::para::{InlineObject, NoteKind};
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block};
|
||||
|
||||
@@ -309,41 +309,6 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with
|
||||
/// content around it the engine's TOC can't reproduce) becomes the engine's TOC before an update,
|
||||
/// so Update Table refreshes it rather than adding a second one: an empty `TOC` field where the
|
||||
/// range started, its old result gone, and the regenerated entries after that paragraph (text
|
||||
/// that followed the range moves below them). Only an unlocked, top-level TOC that isn't inside
|
||||
/// another range field, and only when the body has no engine TOC already.
|
||||
fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
if toc_range(s).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let is_toc = |instr: &str| {
|
||||
let field = FieldCode::parse(instr);
|
||||
field.name == "TOC" && !field.has('c')
|
||||
};
|
||||
let Some(r) = s
|
||||
.doc
|
||||
.field_ranges(StoryRef::Body)
|
||||
.into_iter()
|
||||
.find(|r| r.depth == 0 && !r.locked && is_toc(&r.instr) && r.start.path.depth() == 0 && r.end.path.depth() == 0)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
|
||||
let end = Pos { off: after_end, ..r.end.clone() };
|
||||
let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default();
|
||||
let at = s.doc.delete_range(&r.start, &end)?;
|
||||
let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false };
|
||||
let after = s.doc.insert_object(&at, field, &props)?;
|
||||
if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) {
|
||||
s.doc.split_paragraph(&after)?;
|
||||
}
|
||||
s.clamp_selection();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The TOC field instruction in `p` (not a table of figures).
|
||||
fn toc_field(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
@@ -356,7 +321,6 @@ fn toc_field(p: &Paragraph) -> Option<String> {
|
||||
}
|
||||
|
||||
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
range_toc_to_field(s)?;
|
||||
let replace = v.as_object().is_some_and(|m| !m.is_empty());
|
||||
// Replacing a table puts the new one where the old one was, in place of an empty paragraph.
|
||||
let mut placeholder = false;
|
||||
@@ -444,7 +408,6 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one
|
||||
/// the entries had (Word keeps it in the entries' tab stops, not in the field).
|
||||
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdError> {
|
||||
range_toc_to_field(s)?;
|
||||
let Some((start, end)) = toc_range(s) else { return Ok(()) };
|
||||
let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) };
|
||||
let f = FieldCode::parse(&instr);
|
||||
|
||||
+16
-348
@@ -65,13 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if w.is_empty() {
|
||||
return Err(CmdError::Params("no word".into()));
|
||||
}
|
||||
// The same rule as loading the dictionary file, so a word added is never dropped later.
|
||||
if wordcraft_proof::custom_word(&w).is_none() {
|
||||
let max = wordcraft_proof::MAX_CUSTOM_WORD_CHARS;
|
||||
return Err(CmdError::Params(format!("{w:?} isn't a single word of at most {max} characters")));
|
||||
}
|
||||
wordcraft_proof::add_word(w.trim());
|
||||
s.persist_user_dictionary(&[]);
|
||||
wordcraft_proof::add_word(&w);
|
||||
s.relayout();
|
||||
Ok(json!({"added": w}))
|
||||
})
|
||||
@@ -84,10 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
// For this session only, as in Word: not added to (or saved with) the dictionary.
|
||||
if !w.trim().is_empty() {
|
||||
wordcraft_proof::ignore_word(w.trim());
|
||||
}
|
||||
wordcraft_proof::add_word(&w);
|
||||
s.relayout();
|
||||
Ok(json!({"ignored": w}))
|
||||
})
|
||||
@@ -451,42 +442,9 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
||||
// The change at the caret: the run around it, else the paragraph mark right after it,
|
||||
// else the paragraph's formatting, else the table cell's.
|
||||
let found = s.doc.para_at(&a).and_then(|p| {
|
||||
let runs: Vec<_> = p.run_ranges().collect();
|
||||
let i = runs
|
||||
.iter()
|
||||
.position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
|
||||
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
|
||||
let key = (runs[i].1.ins, runs[i].1.del);
|
||||
if key == (None, None) {
|
||||
return Some(start..end);
|
||||
}
|
||||
// Comment anchors split a revision's text into several runs; the revision goes on past them.
|
||||
let is_anchor =
|
||||
|k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
|
||||
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
|
||||
let mut k = i;
|
||||
while k > 0 {
|
||||
if same(k - 1) {
|
||||
k -= 1;
|
||||
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
|
||||
k -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
start = runs[k].0.start;
|
||||
}
|
||||
let mut k = i;
|
||||
while k + 1 < runs.len() {
|
||||
if same(k + 1) {
|
||||
k += 1;
|
||||
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
|
||||
k += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
end = runs[k].0.end;
|
||||
}
|
||||
Some(start..end)
|
||||
p.run_ranges()
|
||||
.find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))
|
||||
.map(|(r, _)| r)
|
||||
});
|
||||
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
|
||||
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
|
||||
@@ -618,90 +576,6 @@ pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// A tracked deletion in the body shown in a balloon (Show revisions in balloons).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeletionBalloon {
|
||||
/// Where the deleted text was: the balloon's anchor.
|
||||
pub at: Pos,
|
||||
pub author: String,
|
||||
/// The deleted text, a deleted paragraph mark as ¶, shortened to [`DELETION_TEXT_CHARS`].
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// At most this many characters of deleted text show in a balloon (a deleted page shows its start).
|
||||
pub const DELETION_TEXT_CHARS: usize = 200;
|
||||
/// At most this many deletion balloons (a document of many small deletions can't flood the area).
|
||||
const MAX_DELETION_BALLOONS: usize = 500;
|
||||
|
||||
/// The tracked deletions in the body, for balloons, in document order. Deleted text running on
|
||||
/// across runs, revisions and paragraph marks by the same author is one balloon. The source of a
|
||||
/// move is left out: it has its "Moved from" balloon ([`move_changes`]).
|
||||
pub fn deletion_balloons(s: &Session) -> Vec<DeletionBalloon> {
|
||||
let doc = &s.doc;
|
||||
let author = |rev: u32| doc.revisions.get(rev as usize).map(|r| r.author.clone()).unwrap_or_default();
|
||||
let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
let mut out = Vec::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (r, c) in p.run_ranges() {
|
||||
// Inserted and then deleted text is a deletion (it's drawn and left out as one).
|
||||
let Some(rev) = plain_deletion(doc, c.del) else { continue };
|
||||
push_deletion(&mut out, mk(&path, r.start), mk(&path, r.end), author(rev), p.text.get(r).unwrap_or(""));
|
||||
}
|
||||
if let Some(rev) = plain_deletion(doc, p.mark.del) {
|
||||
let at = mk(&path, p.len());
|
||||
let next = path.with_last(path.last().saturating_add(1));
|
||||
let end = if super::has_next_para(s, StoryRef::Body, &path) { mk(&next, 0) } else { at.clone() };
|
||||
push_deletion(&mut out, at, end, author(rev), "¶");
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(d, _, _)| d).collect()
|
||||
}
|
||||
|
||||
/// Add `author`'s deleted `text` (from `at` to `end`) to `out`: to the last balloon when that
|
||||
/// deletion ends at `at` and is theirs, else as a new one. Each balloon carries where its deletion
|
||||
/// ends and how many characters its text has.
|
||||
fn push_deletion(out: &mut Vec<(DeletionBalloon, Pos, usize)>, at: Pos, end: Pos, author: String, text: &str) {
|
||||
let joins = out.last().is_some_and(|(d, e, _)| *e == at && d.author == author);
|
||||
if !joins {
|
||||
if out.len() >= MAX_DELETION_BALLOONS {
|
||||
return;
|
||||
}
|
||||
out.push((DeletionBalloon { at, author, text: String::new() }, end.clone(), 0));
|
||||
}
|
||||
let Some((d, e, chars)) = out.last_mut() else { return };
|
||||
*e = end;
|
||||
for c in text.chars() {
|
||||
if *chars > DELETION_TEXT_CHARS {
|
||||
break;
|
||||
}
|
||||
let c = match c {
|
||||
// Pictures, fields and other objects show no text of their own.
|
||||
wordcraft_doc::para::OBJ => continue,
|
||||
'\t' | wordcraft_doc::para::LINE_BREAK | wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK => ' ',
|
||||
c if c.is_control() => continue,
|
||||
c => c,
|
||||
};
|
||||
*chars += 1;
|
||||
d.text.push(if *chars > DELETION_TEXT_CHARS { '…' } else { c });
|
||||
}
|
||||
}
|
||||
|
||||
/// A deletion revision that is not the source of a move (`w:moveFrom` is a deletion revision
|
||||
/// with a move name).
|
||||
fn plain_deletion(doc: &wordcraft_doc::Document, del: Option<u32>) -> Option<u32> {
|
||||
del.filter(|_| doc.move_name(del).is_none())
|
||||
}
|
||||
|
||||
/// Whether the body has any tracked deletion other than a move's source (cheap: stops at the
|
||||
/// first).
|
||||
pub fn has_deletions(doc: &wordcraft_doc::Document) -> bool {
|
||||
doc.para_paths(StoryRef::Body).iter().any(|path| {
|
||||
doc.para(StoryRef::Body, path)
|
||||
.is_some_and(|p| plain_deletion(doc, p.mark.del).is_some() || p.runs.iter().any(|r| plain_deletion(doc, r.props.del).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let list = changes(s);
|
||||
let caret = s.sel.ordered();
|
||||
@@ -753,11 +627,7 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || super::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let words = wordcraft_doc::count_words(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
@@ -784,13 +654,15 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issues (spelling + grammar) in one paragraph as positions, each in its run's language and
|
||||
/// skipping text left unchecked — the same issues the layout underlines.
|
||||
/// Issues (spelling + grammar) in one paragraph as positions.
|
||||
fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec<(Pos, Pos, wordcraft_proof::Issue)> {
|
||||
let Some(p) = s.doc.para(story, path) else { return Vec::new() };
|
||||
let runs = wordcraft_layout::para::proof_runs(&s.doc, p, None);
|
||||
wordcraft_layout::para::paragraph_issues(p, &runs, &s.prefs.spelling(), true)
|
||||
.into_iter()
|
||||
let text = wordcraft_layout::para::proof_text(p);
|
||||
let mut v: Vec<wordcraft_proof::Issue> = wordcraft_proof::check_spelling_with(&text, &s.prefs.spelling());
|
||||
v.extend(wordcraft_proof::check_grammar(&text));
|
||||
v.sort_by_key(|i| i.start);
|
||||
v.into_iter()
|
||||
.filter(|i| !p.run_ranges().any(|(r, c)| r.start < i.end && i.start < r.end && (c.no_proof == Some(true) || c.link.is_some())))
|
||||
.map(|i| (Pos { story, path: path.clone(), off: i.start }, Pos { story, path: path.clone(), off: i.end }, i))
|
||||
.collect()
|
||||
}
|
||||
@@ -802,12 +674,8 @@ fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)>
|
||||
|
||||
fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value {
|
||||
let word = s.doc.para_at(a).and_then(|p| p.text.get(a.off..b.off)).unwrap_or("").to_string();
|
||||
let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest_in(&word, 6, i.lang) } else { i.suggestions.clone() };
|
||||
json!({
|
||||
"start": pos_json(a), "end": pos_json(b), "text": word,
|
||||
"kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" },
|
||||
"message": i.message, "suggestions": sugg, "lang": i.lang.code(),
|
||||
})
|
||||
let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest(&word, 6) } else { i.suggestions.clone() };
|
||||
json!({"start": pos_json(a), "end": pos_json(b), "text": word, "kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" }, "message": i.message, "suggestions": sugg})
|
||||
}
|
||||
|
||||
/// F7: select the next issue after the caret and return it with suggestions.
|
||||
@@ -1031,210 +899,10 @@ mod tests {
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00");
|
||||
}
|
||||
|
||||
fn glyph_text(d: &[Draw]) -> String {
|
||||
d.iter().filter_map(|x| if let Draw::Glyphs { text, .. } = x { Some(text.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Show revisions in balloons: a tracked deletion leaves the text for a balloon at its place
|
||||
/// and keeps its change bar; it comes back inline in the other modes and wherever no balloons
|
||||
/// are shown (here the Comments pane, which changes the view without a relayout call).
|
||||
#[test]
|
||||
fn revisions_in_balloons_take_deletions_out_of_the_text() {
|
||||
use super::{DeletionBalloon, deletion_balloons};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "keep gone stay"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 10}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
|
||||
// The default (only comments and formatting in balloons): struck through inline.
|
||||
assert!(!s.revisions_in_balloons());
|
||||
let (d, _) = draws(&mut s);
|
||||
assert!(glyph_text(&d).contains("gone"), "{:?}", glyph_text(&d));
|
||||
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
let (d, body_x) = draws(&mut s);
|
||||
assert!(!glyph_text(&d).contains("gone"), "the deletion left the text: {:?}", glyph_text(&d));
|
||||
let horizontal = lines(&d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count();
|
||||
assert_eq!(horizontal, 0, "no strikethrough left behind: {:?}", lines(&d));
|
||||
assert_eq!(lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(), 1, "the changed line keeps its bar");
|
||||
let at = wordcraft_doc::Pos::body(0, 5);
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: at.clone(), author: "Ana".into(), text: "gone ".into() }]);
|
||||
// The balloon's anchor is where the deletion was: between "keep " and "stay".
|
||||
let l = s.layout();
|
||||
let (anchor, stay) = (l.caret(&at).unwrap().x, l.caret(&wordcraft_doc::Pos::body(0, 10)).unwrap().x);
|
||||
assert!((anchor - stay).abs() < 0.01, "the deletion takes no room: {anchor} vs {stay}");
|
||||
|
||||
// No balloons beside the page while the Comments pane lists comments: inline again.
|
||||
s.run("view.commentsPane", &json!({"value": true})).unwrap();
|
||||
assert!(!s.revisions_in_balloons());
|
||||
assert!(glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
s.run("view.commentsPane", &json!({"value": false})).unwrap();
|
||||
assert!(!glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
s.run("review.trackingOptions", &json!({"balloons": "inline"})).unwrap();
|
||||
assert!(!s.revisions_in_balloons());
|
||||
assert!(glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
}
|
||||
|
||||
/// Deleted text running on across runs, separate deletions and a paragraph mark is one balloon
|
||||
/// per author; a long deletion shows its start.
|
||||
#[test]
|
||||
fn deletion_balloons_join_runs_and_paragraph_marks_and_stay_short() {
|
||||
use super::{DELETION_TEXT_CHARS, deletion_balloons};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "one two\nthree four"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
// "two", then the paragraph mark and "three" in a second deletion: one balloon.
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 4}, "focus": {"block": 0, "off": 7}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 7}, "focus": {"block": 1, "off": 5}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
// Bo's deletion right after Ana's is a balloon of its own.
|
||||
s.author = "Bo".into();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 5}, "focus": {"block": 1, "off": 10}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let b = deletion_balloons(&s);
|
||||
let got: Vec<(&str, &str)> = b.iter().map(|d| (d.author.as_str(), d.text.as_str())).collect();
|
||||
assert_eq!(got, vec![("Ana", "two¶three"), ("Bo", " four")]);
|
||||
assert_eq!(b[0].at, wordcraft_doc::Pos::body(0, 4));
|
||||
|
||||
// A deletion far longer than a balloon shows: its first characters and an ellipsis.
|
||||
let mut s = Session::new(Document::new());
|
||||
let long = "word ".repeat(400);
|
||||
s.run("document.setText", &json!({"text": long})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let b = deletion_balloons(&s);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_eq!(b[0].text.chars().count(), DELETION_TEXT_CHARS + 1);
|
||||
assert!(b[0].text.starts_with("word word") && b[0].text.ends_with('…'));
|
||||
}
|
||||
|
||||
/// #404: the source of a move is a deletion revision with a move name. With revisions in
|
||||
/// balloons it leaves the text like a deletion but has only its "Moved from" balloon, not a
|
||||
/// "Deleted: …" one as well; a plain deletion right after it is a balloon of its own.
|
||||
#[test]
|
||||
fn a_moves_source_gets_no_deletion_balloon() {
|
||||
use super::{DeletionBalloon, deletion_balloons, has_deletions, move_changes};
|
||||
use wordcraft_doc::Pos;
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("file.setAuthor", &json!({"name": "Ana"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
// The second paragraph, with its mark, cut and pasted before the first: a move.
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
|
||||
let ends: Vec<(Pos, bool)> = move_changes(&s).into_iter().map(|(at, _, from)| (at, from)).collect();
|
||||
assert_eq!(ends, vec![(Pos::body(0, 0), false), (Pos::body(2, 0), true)]);
|
||||
assert!(deletion_balloons(&s).is_empty(), "the move's source has its \"Moved from\" balloon only: {:?}", deletion_balloons(&s));
|
||||
assert!(!has_deletions(&s.doc), "a move alone needs no deletion balloons");
|
||||
assert_eq!(glyph_text(&draws(&mut s).0), "Second one.First one.Third one.", "the moved-away text left the text");
|
||||
|
||||
// "Third " deleted right where the moved-away paragraph ends: not joined to it.
|
||||
s.run("select.range", &json!({"anchor": {"block": 3, "off": 0}, "focus": {"block": 3, "off": 6}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert!(has_deletions(&s.doc));
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(3, 0), author: "Ana".into(), text: "Third ".into() }]);
|
||||
assert_eq!(move_changes(&s).len(), 2, "the move keeps both its balloons");
|
||||
}
|
||||
|
||||
/// Revisions in balloons take deletions out of the body only, where their balloons are:
|
||||
/// headers, footers, footnotes, endnotes and text boxes get no balloons, so their deletions
|
||||
/// stay in the text, struck through, as in the other modes.
|
||||
#[test]
|
||||
fn deletions_outside_the_body_stay_inline_when_revisions_go_in_balloons() {
|
||||
use super::{DeletionBalloon, deletion_balloons};
|
||||
use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "Body zeta keep"})).unwrap();
|
||||
let part = |v: serde_json::Value, key: &str| StoryRef::Part(v[key].as_u64().unwrap() as u32);
|
||||
let header = part(s.run("insert.header", &json!({"text": "Head alpha"})).unwrap(), "story");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
let footer = part(s.run("insert.footer", &json!({"text": "Foot beta"})).unwrap(), "story");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
let in_body = json!({"pos": {"block": 0, "off": 4}});
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let footnote = part(s.run("references.footnote", &json!({"text": "Note gamma"})).unwrap(), "id");
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let endnote = part(s.run("references.endnote", &json!({"text": "End delta"})).unwrap(), "id");
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let text_box = part(s.run("insert.textBox", &json!({"text": "Box epsilon"})).unwrap(), "story");
|
||||
|
||||
// With Track Changes on, a word deleted in each story.
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
let words = [(StoryRef::Body, "zeta"), (header, "alpha"), (footer, "beta"), (footnote, "gamma"), (endnote, "delta"), (text_box, "epsilon")];
|
||||
for (story, word) in words {
|
||||
let at = s.doc.para(story, &Path::top(0)).and_then(|p| p.text.find(word)).unwrap();
|
||||
let pos = |off: usize| json!({"story": story, "path": [0], "off": off});
|
||||
s.run("select.range", &json!({"anchor": pos(at), "focus": pos(at + word.len())})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let p = s.doc.para(story, &Path::top(0)).unwrap();
|
||||
assert!(p.run_ranges().any(|(r, c)| c.del.is_some() && p.text.get(r) == Some(word)), "{word} is a tracked deletion");
|
||||
}
|
||||
let struck = |d: &[Draw]| lines(d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count();
|
||||
|
||||
// Comments and formatting in balloons: every deletion struck through where it was.
|
||||
let (d, _) = draws(&mut s);
|
||||
let text = glyph_text(&d);
|
||||
for (_, word) in words {
|
||||
assert!(text.contains(word), "{word} in {text:?}");
|
||||
}
|
||||
let all_struck = struck(&d);
|
||||
|
||||
// Revisions in balloons: only the body's deletion leaves the text, for its balloon.
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
let (d, _) = draws(&mut s);
|
||||
let text = glyph_text(&d);
|
||||
assert!(!text.contains("zeta"), "the body's deletion left the text: {text:?}");
|
||||
for (_, word) in &words[1..] {
|
||||
assert!(text.contains(word), "{word} stays inline: {text:?}");
|
||||
}
|
||||
assert_eq!(struck(&d), all_struck - 1, "the other deletions stay struck through");
|
||||
// Its balloon is the only one: the notes' and text box's objects sit before "zeta".
|
||||
let at = s.doc.para(StoryRef::Body, &Path::top(0)).and_then(|p| p.text.find("zeta")).unwrap();
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(0, at), author: "Ana".into(), text: "zeta".into() }]);
|
||||
}
|
||||
|
||||
fn body_para(s: &Session) -> &wordcraft_doc::Paragraph {
|
||||
s.doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap()
|
||||
}
|
||||
|
||||
/// A comment inside a tracked insertion splits it into several runs; Accept and Reject at the
|
||||
/// caret resolve the whole insertion, not just the run around the caret.
|
||||
#[test]
|
||||
fn accept_and_reject_cover_a_change_split_by_a_comment() {
|
||||
for accept in [true, false] {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "hello world"})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 3}, "focus": {"block": 0, "off": 8}})).unwrap();
|
||||
s.run("review.newComment", &json!({"text": "note"})).unwrap();
|
||||
assert!(body_para(&s).runs.len() > 3, "the comment anchors split the insertion");
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 1}, "focus": {"block": 0, "off": 1}})).unwrap();
|
||||
s.run(if accept { "review.accept" } else { "review.reject" }, &json!({})).unwrap();
|
||||
let p = body_para(&s);
|
||||
assert!(p.runs.iter().all(|r| r.props.ins.is_none()), "no tracked insertion is left");
|
||||
// The comment's anchors are objects in the text; read past them.
|
||||
let shown: String = p.text.chars().filter(|c| *c != wordcraft_doc::para::OBJ).collect();
|
||||
assert_eq!(shown.contains("hello"), accept, "{:?}", p.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under
|
||||
/// Track Changes by Ana.
|
||||
fn formatted_while_tracking() -> Session {
|
||||
@@ -1269,7 +937,7 @@ mod tests {
|
||||
|
||||
let list = s.run("review.changes", &json!({})).unwrap();
|
||||
let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect();
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Bold cs, Size: 14, Size cs: 14"], "{list:#}");
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}");
|
||||
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count();
|
||||
|
||||
+35
-223
@@ -1,10 +1,8 @@
|
||||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::locale_number::{format_number, parse_number};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -177,7 +175,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
select_cells(s, &tp, Some((r, None)))
|
||||
})
|
||||
.pure()),
|
||||
t(CommandSpec::new("table.selectColumn", "Select Column", "Table Layout › Table › Select", select_column).pure()),
|
||||
t(CommandSpec::new("table.selectCell", "Select Cell", "Table Layout › Table › Select", |s, _| {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
select_cells(s, &tp, Some((r, Some(c))))
|
||||
@@ -387,13 +384,6 @@ fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult {
|
||||
|
||||
/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell).
|
||||
fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
|
||||
// Whole cells selected (a column, say): just those, by grid column, so merged cells count.
|
||||
if let Some(range) = s.cell_range()
|
||||
&& s.sel.focus.path.cell().is_some_and(|(tp, _, _)| tp == range.table)
|
||||
&& let Some(t) = s.doc.table(range.story, &range.table)
|
||||
{
|
||||
return range.cells(t);
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() };
|
||||
if ta != tb {
|
||||
@@ -464,8 +454,7 @@ fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
|
||||
pub(super) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
let n = cells.iter().map(|x| x.0).collect::<std::collections::BTreeSet<_>>().len().max(1);
|
||||
@@ -476,8 +465,7 @@ pub(super) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
fix_caret(s, &tp, if below { r + 1 } else { r }, c)
|
||||
}
|
||||
|
||||
/// Insert Left / Right: a column beside the caret's cell; the caret goes into it.
|
||||
pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
let g = t.grid_col(r, c);
|
||||
@@ -630,47 +618,22 @@ fn cell_text(c: &wordcraft_doc::Cell) -> String {
|
||||
c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The language of a cell's text: that of its first paragraph with text.
|
||||
fn cell_lang(doc: &wordcraft_doc::Document, c: &wordcraft_doc::Cell) -> Option<String> {
|
||||
c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(doc, p))
|
||||
}
|
||||
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| super::default_lang(s));
|
||||
// A total order (numbers, then text), so sorting never sees inconsistent comparisons.
|
||||
let keys: Vec<(Option<f64>, SortKey)> = column()
|
||||
.map(|c| {
|
||||
let text = c.map(cell_text).unwrap_or_default();
|
||||
let num_lang = c.and_then(|c| cell_lang(&s.doc, c)).or_else(|| lang.clone());
|
||||
let num = parse_number(text.trim(), num_lang.as_deref()).filter(|x| x.is_finite());
|
||||
(num, if num.is_some() { SortKey::default() } else { sort_key(text.trim(), lang.as_deref()) })
|
||||
})
|
||||
.collect();
|
||||
with_table(s, move |t| {
|
||||
if skip > t.rows.len() {
|
||||
return;
|
||||
}
|
||||
let body = t.rows.split_off(skip);
|
||||
let mut rows: Vec<_> = keys.iter().cloned().zip(body).collect();
|
||||
rows.sort_by(|((a, ka), _), ((b, kb), _)| {
|
||||
let o = match (a, b) {
|
||||
(Some(x), Some(y)) => x.total_cmp(y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => ka.cmp(kb),
|
||||
with_table(s, |t| {
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header)));
|
||||
let Some(body) = t.rows.get_mut(skip..) else { return };
|
||||
body.sort_by(|a, b| {
|
||||
let ka = a.cells.get(col).map(cell_text).unwrap_or_default();
|
||||
let kb = b.cells.get(col).map(cell_text).unwrap_or_default();
|
||||
let o = match (ka.trim().replace(',', "").parse::<f64>(), kb.trim().replace(',', "").parse::<f64>()) {
|
||||
(Ok(x), Ok(y)) => x.total_cmp(&y),
|
||||
_ => ka.to_lowercase().cmp(&kb.to_lowercase()),
|
||||
};
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
t.rows.extend(rows.into_iter().map(|(_, r)| r));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -701,27 +664,14 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase();
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let story = s.sel.focus.story;
|
||||
// Each cell's number is read in that cell's language (`3,50` in Polish is three and a half).
|
||||
let sources: Vec<&wordcraft_doc::Cell> = if f.contains("LEFT") {
|
||||
t.rows.get(r).map(|row| row.cells.iter().take(c).collect()).unwrap_or_default()
|
||||
let nums: Vec<f64> = if f.contains("LEFT") {
|
||||
t.rows
|
||||
.get(r)
|
||||
.map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).collect()
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()
|
||||
};
|
||||
let default_lang = super::default_lang(s);
|
||||
let nums: Vec<f64> = sources
|
||||
.iter()
|
||||
.filter_map(|x| parse_number(cell_text(x).trim(), cell_lang(&s.doc, x).or_else(|| default_lang.clone()).as_deref()))
|
||||
.filter(|x| x.is_finite())
|
||||
.collect();
|
||||
// The result is written in the language of the formula's cell, else of the numbers it adds.
|
||||
let out_lang = s
|
||||
.doc
|
||||
.para(story, &s.sel.focus.path)
|
||||
.filter(|p| !p.plain_text().trim().is_empty())
|
||||
.and_then(|p| super::para_lang(&s.doc, p))
|
||||
.or_else(|| sources.iter().find_map(|x| cell_lang(&s.doc, x)))
|
||||
.or(default_lang);
|
||||
let val = if f.starts_with("=AVERAGE") {
|
||||
if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 }
|
||||
} else if f.starts_with("=COUNT") {
|
||||
@@ -735,7 +685,7 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
nums.iter().sum()
|
||||
};
|
||||
let text = format_number(val, out_lang.as_deref());
|
||||
let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") };
|
||||
let props = s.typing_props();
|
||||
let at = s.sel.focus.clone();
|
||||
let end = s.doc.insert_object(
|
||||
@@ -750,164 +700,26 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option<usize>)>) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, tp) else { return sel_result(s) };
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match which {
|
||||
None => ((0, last_row), (0, last_col)),
|
||||
Some((r, None)) => ((r, r), (0, last_col)),
|
||||
Some((r, Some(c))) => ((r, r), grid_cols(t, r, c)),
|
||||
let (r0, r1, c0, c1) = match which {
|
||||
None => (0, t.rows.len().saturating_sub(1), 0, usize::MAX),
|
||||
Some((r, None)) => (r, r, 0, usize::MAX),
|
||||
Some((r, Some(c))) => (r, r, c, c),
|
||||
};
|
||||
select_range(s, story, tp, rows, cols)
|
||||
}
|
||||
|
||||
/// The grid columns cell `c` of row `r` covers (first, last).
|
||||
pub(super) fn grid_cols(t: &Table, r: usize, c: usize) -> (usize, usize) {
|
||||
let g = t.grid_col(r, c);
|
||||
let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, wordcraft_doc::Cell::span);
|
||||
(g, g + span - 1)
|
||||
}
|
||||
|
||||
/// Table Layout › Table › Select › Select Column: the grid columns of the selected cells, every row.
|
||||
fn select_column(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
let (mut g0, mut g1) = grid_cols(t, r, c);
|
||||
for (r, c) in selected_cells(s) {
|
||||
let (a, b) = grid_cols(t, r, c);
|
||||
(g0, g1) = (g0.min(a), g1.max(b));
|
||||
}
|
||||
let rows = (0, t.rows.len().saturating_sub(1));
|
||||
select_range(s, story, &tp, rows, (g0, g1))
|
||||
}
|
||||
|
||||
/// Select whole cells of table `tp`: rows `rows`, and of them every cell reaching into grid
|
||||
/// columns `cols` (both inclusive, either order). The selection runs from the first cell's start
|
||||
/// to the last cell's end; a block of the cells' paragraphs (`CellRange`) says which cells they
|
||||
/// are, so the app shades them and, when they aren't one run of the text (part of a column),
|
||||
/// edits and formatting leave the cells between the corners alone.
|
||||
pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> CmdResult {
|
||||
let t = s.doc.table(story, tp).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let rows = (rows.0.min(rows.1).min(last_row), rows.0.max(rows.1).min(last_row));
|
||||
let cols = (cols.0.min(cols.1).min(last_col), cols.0.max(cols.1).min(last_col));
|
||||
let stream = rows.0 == rows.1 || (cols.0 == 0 && cols.1 == last_col);
|
||||
let range = crate::CellRange { story, table: tp.clone(), rows, cols, stream };
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
|
||||
// Every paragraph of the picked cells (nested tables' too), in document order.
|
||||
let depth = tp.0.len();
|
||||
let in_picked = |q: &Path| match (q.0.get(depth), q.0.get(depth + 1)) {
|
||||
(Some(r), Some(c)) => q.0.len() > depth + 2 && q.0.starts_with(&tp.0) && picked.contains(&(*r as usize, *c as usize)),
|
||||
_ => false,
|
||||
};
|
||||
let segments: Vec<(Pos, Pos)> = s
|
||||
.doc
|
||||
.para_paths(story)
|
||||
.into_iter()
|
||||
.filter(|q| in_picked(q))
|
||||
.map(|q| {
|
||||
let len = s.doc.para(story, &q).map_or(0, Paragraph::len);
|
||||
(Pos { story, path: q.clone(), off: 0 }, Pos { story, path: q, off: len })
|
||||
})
|
||||
.collect();
|
||||
let (Some(first), Some(last)) = (segments.first(), segments.last()) else {
|
||||
return Err(CmdError::Failed("there are no cells to select there".into()));
|
||||
};
|
||||
// The end corner is in the last cell's own last paragraph (not a nested table's), so it
|
||||
// names the cell.
|
||||
let focus = segments.iter().rev().find(|(_, b)| b.path.0.len() == depth + 3).map_or(&last.1, |(_, b)| b).clone();
|
||||
let sel = Selection { anchor: first.0.clone(), focus };
|
||||
s.sel = sel.clone();
|
||||
s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) });
|
||||
s.column_mode = false;
|
||||
s.widened = None;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
let last_c = t.rows.get(r1).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0).min(c1);
|
||||
let mut a = tp.0.clone();
|
||||
a.extend([r0 as u32, c0 as u32, 0]);
|
||||
let mut b = tp.0.clone();
|
||||
b.extend([r1 as u32, last_c as u32]);
|
||||
let end_path = s.doc.para_paths(story).into_iter().rfind(|q| q.0.starts_with(&b)).unwrap_or_else(|| {
|
||||
let mut x = b.clone();
|
||||
x.push(0);
|
||||
Path(x)
|
||||
});
|
||||
let off = s.doc.para(story, &end_path).map(|x| x.len()).unwrap_or(0);
|
||||
s.sel = Selection { anchor: Pos { story, path: Path(a), off: 0 }, focus: Pos { story, path: end_path, off } };
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Extend the selection from its anchor to `to` the way Word does around tables: from one cell
|
||||
/// into another of the same table it takes whole cells, the rectangle between them; between a
|
||||
/// table's cell and text outside the table, whole rows, from the cell's row out to the table's
|
||||
/// edge facing the other end. Where the selection really runs is kept (`Session::sel_ends`), so
|
||||
/// extending it again goes on from there.
|
||||
pub(crate) fn extend_selection(s: &mut Session, to: Pos) {
|
||||
let ends = Selection { anchor: s.sel.anchor.clone(), focus: to };
|
||||
// Shift+Up and Shift+Down keep their goal column.
|
||||
let goal_x = s.goal_x;
|
||||
s.sel = ends.clone();
|
||||
s.column = None;
|
||||
widen(s, &ends);
|
||||
s.widened = if s.sel == ends { None } else { Some(crate::session::Widened { sel: s.sel.clone(), ends }) };
|
||||
s.goal_x = goal_x;
|
||||
}
|
||||
|
||||
/// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see
|
||||
/// `extend_selection`).
|
||||
fn widen(s: &mut Session, ends: &Selection) {
|
||||
let (a, f) = (&ends.anchor, &ends.focus);
|
||||
if a.story != f.story {
|
||||
return;
|
||||
}
|
||||
// Down through the tables both ends are in, one nesting level (three indexes) at a time.
|
||||
let mut i = 0;
|
||||
while let (Some(ab), Some(fb)) = (a.path.0.get(i), f.path.0.get(i)) {
|
||||
match (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) {
|
||||
// The same cell: look inside it.
|
||||
(Some(x), Some(y)) if ab == fb && x == y => i += 3,
|
||||
// Two cells of one table: the rectangle between them.
|
||||
(Some((tp, ra, ca)), Some((_, rf, cf))) if ab == fb => {
|
||||
let cols = s.doc.table(a.story, &tp).map(|t| {
|
||||
let ((g0, g1), (h0, h1)) = (grid_cols(t, ra, ca), grid_cols(t, rf, cf));
|
||||
(g0.min(h0), g1.max(h1))
|
||||
});
|
||||
if let Some(cols) = cols {
|
||||
// A table without paragraphs keeps the plain selection.
|
||||
let _ = select_range(s, a.story, &tp, (ra, rf), cols);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Different blocks: an end in a table takes its whole row, out to the table's edge
|
||||
// facing the other end.
|
||||
(x, y) if ab != fb => {
|
||||
let forward = ab < fb;
|
||||
if let Some((tp, r, _)) = x
|
||||
&& let Some((start, end)) = row_ends(&s.doc, a.story, &tp, r)
|
||||
{
|
||||
s.sel.anchor = if forward { start } else { end };
|
||||
}
|
||||
if let Some((tp, r, _)) = y
|
||||
&& let Some((start, end)) = row_ends(&s.doc, f.story, &tp, r)
|
||||
{
|
||||
s.sel.focus = if forward { end } else { start };
|
||||
}
|
||||
return;
|
||||
}
|
||||
// One paragraph.
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell of the table at index `i` of paragraph path `p`, if the paragraph is in one there:
|
||||
/// (the table's path, row, cell).
|
||||
fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> {
|
||||
match (p.get(..=i), p.get(i + 1), p.get(i + 2)) {
|
||||
(Some(t), Some(r), Some(c)) if p.len() > i + 3 => Some((Path(t.to_vec()), *r as usize, *c as usize)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end.
|
||||
fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> {
|
||||
let mut row = tp.0.clone();
|
||||
row.push(u32::try_from(r).ok()?);
|
||||
let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row));
|
||||
let first = paths.next()?;
|
||||
let last = paths.next_back().unwrap_or_else(|| first.clone());
|
||||
let len = doc.para(story, &last).map_or(0, Paragraph::len);
|
||||
Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len }))
|
||||
}
|
||||
|
||||
fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' };
|
||||
let (a, b) = s.sel.ordered();
|
||||
|
||||
@@ -1,864 +0,0 @@
|
||||
//! The rest of Word's table mouse (resizing is `table_resize`): selection areas just above a
|
||||
//! column, left of a row and along a cell's left edge inside it select the column, row or cell,
|
||||
//! and a drag from one selects every one up to the pointer; insert buttons left of a row border
|
||||
//! and above a column border add a row or column there; the move handle just past the table's
|
||||
//! top-left corner selects the table or, dragged, moves it (the table then floats where it's
|
||||
//! dropped, the text wrapping around it, as in Word); a double-click on a column border fits the
|
||||
//! column left of it to its contents. The app sends each click or finished drag here in page
|
||||
//! points, so each is one command (one undo step when it changes the document).
|
||||
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
use super::table_resize::{self, CellBox, Edge, REACH, ResizeTarget};
|
||||
use super::{sel_result, table};
|
||||
use crate::{CellRange, CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// The column selection area: from this far above a table's top border (points) to this far
|
||||
/// below it.
|
||||
const COLUMN_ABOVE: f32 = 6.0;
|
||||
const COLUMN_BELOW: f32 = 1.5;
|
||||
/// How far left of a table (points) the row selection area and the row insert buttons reach.
|
||||
const ROW_LEFT: f32 = 24.0;
|
||||
/// The cell selection area's width inside a cell's left edge (points), at the least; it starts
|
||||
/// past the column border's resize reach.
|
||||
const CELL_EDGE: f32 = 5.5;
|
||||
/// The insert buttons answer this near a border (points), and above the table this far out.
|
||||
const INSERT_REACH: f32 = 3.5;
|
||||
const INSERT_OUT: f32 = 14.0;
|
||||
/// An insert button's centre sits this far out from the table's edge (points).
|
||||
const BUTTON_OUT: f32 = 8.0;
|
||||
/// The move handle's side and its gap from the table's top-left corner (points).
|
||||
const HANDLE: f32 = 8.0;
|
||||
const HANDLE_GAP: f32 = 2.0;
|
||||
/// The farthest a drag moves a table (points; Word's 22 inches).
|
||||
const MAX_MOVE: f32 = 1584.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at)
|
||||
.params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a click on a table's insert button: left of a row border adds a row there, above a column border a column; the new row or column is selected, as in Word)"#,
|
||||
),
|
||||
CommandSpec::new("table.moveDrag", "Move Table by Dragging", "Table Layout › Table", move_drag).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "reach"?: pt} (a drag from the move handle past a table's top-left corner: the table floats where it's dropped, the text wrapping around it; tables in the main text only)"#,
|
||||
),
|
||||
CommandSpec::new("table.fitColumn", "AutoFit Column by Double-Clicking", "Table Layout › Cell Size", fit_column).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a double-click on a column border: the column left of it fits its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin), as in Word)"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// What the pointer is over.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Spot {
|
||||
/// The move handle.
|
||||
Handle,
|
||||
/// The insert button of horizontal border `n` (0 = the table's top, `n` = the bottom of row
|
||||
/// `n - 1`).
|
||||
InsertRow(usize),
|
||||
/// The insert button of grid line `n` (0 = the table's left edge).
|
||||
InsertColumn(usize),
|
||||
/// The selection area above grid column `n`.
|
||||
Column(usize),
|
||||
/// The selection area left of row `n`.
|
||||
Row(usize),
|
||||
/// The selection area along cell (`row`, `cell`)'s left edge.
|
||||
Cell(usize, usize),
|
||||
}
|
||||
|
||||
/// A spot and the table's drawn geometry on the page (`geom.edge` means nothing here).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Target {
|
||||
pub spot: Spot,
|
||||
pub geom: ResizeTarget,
|
||||
}
|
||||
|
||||
/// An insert button: its centre and the border it inserts at, end to end (page points).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct InsertMark {
|
||||
pub center: (f32, f32),
|
||||
pub from: (f32, f32),
|
||||
pub to: (f32, f32),
|
||||
}
|
||||
|
||||
/// The move handle's square (page points), just past the table's top-left corner.
|
||||
pub fn handle(geom: &ResizeTarget) -> Rect {
|
||||
Rect::new(geom.left() - HANDLE_GAP - HANDLE, geom.top() - HANDLE_GAP - HANDLE, HANDLE, HANDLE)
|
||||
}
|
||||
|
||||
/// Whether the table's first row is on this page (its move handle is there).
|
||||
fn starts_here(geom: &ResizeTarget) -> bool {
|
||||
geom.rows.first().is_some_and(|r| r.row == 0)
|
||||
}
|
||||
|
||||
fn on_handle(geom: &ResizeTarget, x: f32, y: f32, reach: f32) -> bool {
|
||||
let h = handle(geom);
|
||||
starts_here(geom) && x >= h.x - reach / 2.0 && x <= h.right() + reach / 2.0 && y >= h.y - reach / 2.0 && y <= h.bottom() + reach / 2.0
|
||||
}
|
||||
|
||||
fn clean_reach(reach: f32) -> f32 {
|
||||
if reach.is_finite() { reach.clamp(0.5, 24.0) } else { REACH }
|
||||
}
|
||||
|
||||
/// Of `lines` (index, position), the one nearest `at` within `reach`.
|
||||
fn nearest(lines: impl Iterator<Item = (usize, f32)>, at: f32, reach: f32) -> Option<usize> {
|
||||
lines.map(|(i, v)| (i, (at - v).abs())).filter(|(_, d)| *d <= reach).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The grid column at page x `x` (the first or the last beyond the table).
|
||||
fn column_at(geom: &ResizeTarget, x: f32) -> usize {
|
||||
let n = geom.cols.len().saturating_sub(1);
|
||||
geom.cols.iter().skip(1).take_while(|cx| **cx <= x).count().min(n.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The drawn row at page y `y` (the first or the last beyond the table).
|
||||
fn row_at(geom: &ResizeTarget, y: f32) -> usize {
|
||||
geom.rows.iter().find(|r| y < r.bottom).or(geom.rows.last()).map_or(0, |r| r.row)
|
||||
}
|
||||
|
||||
/// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection
|
||||
/// areas. `cells` are its cell boxes on the page.
|
||||
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option<Spot> {
|
||||
if on_handle(geom, x, y, reach) {
|
||||
return Some(Spot::Handle);
|
||||
}
|
||||
let (left, right, top, bottom) = (geom.left(), geom.right(), geom.top(), geom.bottom());
|
||||
// Above the table: the insert buttons over its column borders, and the column areas.
|
||||
if y >= top - INSERT_OUT && y <= top + COLUMN_BELOW && x >= left - INSERT_REACH && x <= right + INSERT_REACH {
|
||||
let lines = geom.cols.iter().copied().enumerate().filter(|(i, _)| geom.rows.iter().any(|r| r.edges.contains(i)));
|
||||
if y < top
|
||||
&& let Some(line) = nearest(lines, x, INSERT_REACH)
|
||||
{
|
||||
return Some(Spot::InsertColumn(line));
|
||||
}
|
||||
if y >= top - COLUMN_ABOVE && x >= left && x <= right {
|
||||
return Some(Spot::Column(column_at(geom, x)));
|
||||
}
|
||||
}
|
||||
// Left of the table: the insert buttons beside its row borders, and the row areas.
|
||||
if x >= left - ROW_LEFT && x < left && y >= top - INSERT_REACH && y <= bottom + INSERT_REACH {
|
||||
let borders = geom.rows.iter().flat_map(|r| [(r.row, r.top), (r.row + 1, r.bottom)]);
|
||||
if let Some(line) = nearest(borders, y, INSERT_REACH) {
|
||||
return Some(Spot::InsertRow(line));
|
||||
}
|
||||
if let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom) {
|
||||
return Some(Spot::Row(r.row));
|
||||
}
|
||||
}
|
||||
// Inside a cell, along its left edge.
|
||||
let edge = CELL_EDGE.max(reach + 2.5);
|
||||
cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell))
|
||||
}
|
||||
|
||||
/// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's.
|
||||
/// None on a column or row border or the resize handle: those resize (`table_resize`). `reach`
|
||||
/// is their resize reach (points).
|
||||
pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<Target> {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let reach = clean_reach(reach);
|
||||
if table_resize::resize_target(l, doc, page, x, y, reach).is_some() {
|
||||
return None;
|
||||
}
|
||||
let boxes = table_resize::cell_boxes(l, page);
|
||||
table_resize::tables_on(l, doc, page)
|
||||
.into_iter()
|
||||
.filter(|g| g.story == story)
|
||||
.filter_map(|geom| {
|
||||
let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect();
|
||||
spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom })
|
||||
})
|
||||
.max_by_key(|t| t.geom.table.0.len())
|
||||
}
|
||||
|
||||
/// The table whose move handle shows with the pointer at (`x`, `y`): the innermost table of
|
||||
/// `story` starting on this page that the pointer is over, or on its way to the handle.
|
||||
pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<ResizeTarget> {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let pad = clean_reach(reach) / 2.0;
|
||||
table_resize::tables_on(l, doc, page)
|
||||
.into_iter()
|
||||
.filter(|g| {
|
||||
let h = handle(g);
|
||||
g.story == story && starts_here(g) && x >= h.x - pad && x <= g.right() && y >= h.y - pad && y <= g.bottom()
|
||||
})
|
||||
.max_by_key(|g| g.table.0.len())
|
||||
}
|
||||
|
||||
/// The insert button a target is, if it's one.
|
||||
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
|
||||
let g = &t.geom;
|
||||
match t.spot {
|
||||
Spot::InsertRow(line) => {
|
||||
// The bottom of the row above the border, or the top of the first row.
|
||||
let y = g.rows.iter().find(|r| r.row + 1 == line).map(|r| r.bottom).or_else(|| g.rows.iter().find(|r| r.row == line).map(|r| r.top))?;
|
||||
Some(InsertMark { center: (g.left() - BUTTON_OUT, y), from: (g.left(), y), to: (g.right(), y) })
|
||||
}
|
||||
Spot::InsertColumn(line) => {
|
||||
let x = g.cols.get(line).copied()?;
|
||||
Some(InsertMark { center: (x, g.top() - BUTTON_OUT), from: (x, g.top()), to: (x, g.bottom()) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether dragging the move handle moves the table: tables in the main text, not nested ones.
|
||||
pub fn movable(geom: &ResizeTarget) -> bool {
|
||||
geom.story == StoryRef::Body && geom.table.0.len() == 1
|
||||
}
|
||||
|
||||
/// Where a move-handle drag by (`dx`, `dy`) puts the table's top-left corner on `page`: on the
|
||||
/// page, its part there above the bottom margin.
|
||||
pub fn moved_to(l: &DocLayout, page: usize, geom: &ResizeTarget, dx: f32, dy: f32) -> (f32, f32) {
|
||||
let clean = |d: f32| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
|
||||
let (w, h) = (geom.right() - geom.left(), geom.bottom() - geom.top());
|
||||
let (page_w, body_bottom) = l.pages.get(page).map_or((0.0, 0.0), |pg| (pg.w, pg.body.bottom()));
|
||||
let x = (geom.left() + clean(dx)).clamp(0.0, (page_w - w).max(0.0));
|
||||
let y = (geom.top() + clean(dy)).clamp(0.0, (body_bottom - h).max(0.0));
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Where the selected cells are drawn (page, rect), for shading them.
|
||||
pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usize, Rect)> {
|
||||
let Some(t) = doc.table(range.story, &range.table) else { return Vec::new() };
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
|
||||
let mut out = Vec::new();
|
||||
for (pi, pg) in l.pages.iter().enumerate() {
|
||||
for it in &pg.items {
|
||||
// A vertically merged cell's box spans its rows (#640); its continuation rows'
|
||||
// `covered` boxes would shade it twice.
|
||||
if let Placed::Cell { rect, table, row, cell, story, covered: false } = it
|
||||
&& *story == range.story
|
||||
&& *table == range.table
|
||||
&& picked.contains(&(*row, *cell))
|
||||
{
|
||||
out.push((pi, *rect));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The click: `page`, `x`, `y` and the resize `reach`.
|
||||
fn point(v: &Value) -> Result<(usize, f32, f32, f32), CmdError> {
|
||||
let page =
|
||||
p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?;
|
||||
Ok((page, p::req_f32(v, "x")?, p::req_f32(v, "y")?, p::f32(v, "reach").unwrap_or(REACH)))
|
||||
}
|
||||
|
||||
/// The spot under the click, in the caret's story.
|
||||
fn target(s: &mut Session, page: usize, x: f32, y: f32, reach: f32) -> Result<Target, CmdError> {
|
||||
let l = s.layout();
|
||||
if l.pages.get(page).is_none() {
|
||||
return Err(CmdError::Params(format!("no page {page}")));
|
||||
}
|
||||
target_at(&l, &s.doc, s.sel.focus.story, page, x, y, reach)
|
||||
.ok_or_else(|| CmdError::Failed("there's no table selection area or button there".into()))
|
||||
}
|
||||
|
||||
fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
let to = p::f32(v, "toX").zip(p::f32(v, "toY"));
|
||||
let story = geom.story;
|
||||
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match spot {
|
||||
Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))),
|
||||
Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)),
|
||||
Spot::Cell(r, c) => {
|
||||
let (g0, g1) = table::grid_cols(t, r, c);
|
||||
match to {
|
||||
Some((tx, ty)) => {
|
||||
let g = column_at(&geom, tx);
|
||||
((r, row_at(&geom, ty)), (g0.min(g), g1.max(g)))
|
||||
}
|
||||
None => ((r, r), (g0, g1)),
|
||||
}
|
||||
}
|
||||
Spot::Handle => ((0, last_row), (0, last_col)),
|
||||
Spot::InsertRow(_) | Spot::InsertColumn(_) => return Err(CmdError::Failed("there's an insert button there, not a selection area".into())),
|
||||
};
|
||||
table::select_range(s, story, &geom.table, rows, cols)
|
||||
}
|
||||
|
||||
/// A cell to insert a column beside at grid line `line`: one starting there (the column goes
|
||||
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
|
||||
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
|
||||
t.rows.iter().enumerate().find_map(|(r, row)| {
|
||||
let mut g = 0usize;
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if g == line {
|
||||
return Some((r, c, false));
|
||||
}
|
||||
g = g.saturating_add(cell.span());
|
||||
if g == line && c + 1 == row.cells.len() {
|
||||
return Some((r, c, true));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
let story = geom.story;
|
||||
// The row or column goes beside a cell at the border, as Insert Above / Below / Left / Right
|
||||
// would put it there, so it takes the border's index (`line`); then, as in Word, it's selected.
|
||||
let (r, c, after, rows, line) = match spot {
|
||||
Spot::InsertRow(line) => match line.checked_sub(1) {
|
||||
Some(above) => (above, 0, true, true, line),
|
||||
None => (0, 0, false, true, line),
|
||||
},
|
||||
Spot::InsertColumn(line) => {
|
||||
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (r, c, after) = beside_line(t, line).ok_or_else(|| CmdError::Failed("there's no column border there".into()))?;
|
||||
(r, c, after, false, line)
|
||||
}
|
||||
_ => return Err(CmdError::Failed("there's a selection area there, not an insert button".into())),
|
||||
};
|
||||
let size = |s: &Session| s.doc.table(story, &geom.table).map(|t| (t.rows.len(), t.cols()));
|
||||
let before = size(s);
|
||||
let mut path = geom.table.0.clone();
|
||||
path.extend([r as u32, c as u32, 0]);
|
||||
s.column = None;
|
||||
s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 });
|
||||
if rows {
|
||||
table::row(s, after)?;
|
||||
} else {
|
||||
table::col(s, after)?;
|
||||
}
|
||||
if size(s) == before {
|
||||
return Err(CmdError::Failed("the table can't take another row or column".into()));
|
||||
}
|
||||
let (sel_rows, sel_cols) = if rows { ((line, line), (0, usize::MAX)) } else { ((0, usize::MAX), (line, line)) };
|
||||
table::select_range(s, story, &geom.table, sel_rows, sel_cols)
|
||||
}
|
||||
|
||||
/// Where table `table` of `story` is first drawn: its page and top-left corner.
|
||||
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
l.pages.iter().enumerate().find_map(|(pi, pg)| {
|
||||
let cells = pg.items.iter().filter_map(|it| match it {
|
||||
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
cells.fold(None, |acc: Option<(f32, f32)>, r| Some(acc.map_or((r.x, r.y), |(x, y)| (x.min(r.x), y.min(r.y))))).map(|(x, y)| (pi, x, y))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?);
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
if spot != Spot::Handle {
|
||||
return Err(CmdError::Failed("there's no table move handle there".into()));
|
||||
}
|
||||
if !movable(&geom) {
|
||||
return Err(CmdError::Failed("only a table in the main text can be moved".into()));
|
||||
}
|
||||
let (gx, gy) = moved_to(&s.layout(), page, &geom, to_x - x, to_y - y);
|
||||
if (gx - geom.left()).abs() < 0.01 && (gy - geom.top()).abs() < 0.01 {
|
||||
return sel_result(s);
|
||||
}
|
||||
// Dragged, a table floats where it's dropped, the text wrapping around it (Word): placed from
|
||||
// its column and the text where it stands, 0.125" from the text at its sides.
|
||||
let t = s.doc.table_mut(geom.story, &geom.table)?;
|
||||
let mut f = t.props.float.unwrap_or(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
f.h_align = None;
|
||||
f.v_align = None;
|
||||
t.props.float = Some(f);
|
||||
table_resize::touch(s, &geom)?;
|
||||
// Placed by offsets, the table moves as much as they do: one correction from where it landed
|
||||
// puts it where it was dropped.
|
||||
if let Some((p0, x0, y0)) = corner(&s.layout(), geom.story, &geom.table)
|
||||
&& p0 == page
|
||||
{
|
||||
let fix = |v: f32| if v.is_finite() { v.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
|
||||
let t = s.doc.table_mut(geom.story, &geom.table)?;
|
||||
if let Some(f) = t.props.float.as_mut() {
|
||||
f.x = fix(f.x + gx - x0);
|
||||
f.y = fix(f.y + gy - y0);
|
||||
}
|
||||
table_resize::touch(s, &geom)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn fit_column(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let l = s.layout();
|
||||
let Some((target, line)) = table_resize::resize_target(&l, &s.doc, page, x, y, reach).and_then(|t| match t.edge {
|
||||
Edge::Column(line) => Some((t, line)),
|
||||
_ => None,
|
||||
}) else {
|
||||
return Err(CmdError::Failed("there's no column border there".into()));
|
||||
};
|
||||
let t = s.doc.table(target.story, &target.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let g = line.checked_sub(1).ok_or_else(|| CmdError::Failed("there's no column left of that border".into()))?;
|
||||
let (narrowest, widest) =
|
||||
wordcraft_layout::measure_table_columns(&s.doc, t).get(g).copied().ok_or_else(|| CmdError::Failed("there's no such column".into()))?;
|
||||
let current = target.widths().get(g).copied().unwrap_or(0.0);
|
||||
// As in Word, the columns right of it keep their widths and move with the border, so the
|
||||
// table narrows or widens: up to the right margin, never below the column's narrowest content.
|
||||
let room = current + l.pages.get(page).map_or(0.0, |pg| (pg.body.right() - target.right()).max(0.0));
|
||||
let want = widest.min(room.max(narrowest));
|
||||
if let Some(w) = target.plan(want - current, 0.0, true).widths {
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
table_resize::set_widths(t, w);
|
||||
table_resize::touch(s, &target)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::props::Align;
|
||||
|
||||
fn table_session(rows: usize, cols: usize) -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("insert.table", &json!({"rows": rows, "cols": cols})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn table_path(s: &Session) -> Path {
|
||||
Path(vec![s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32])
|
||||
}
|
||||
|
||||
fn first_table(s: &Session) -> Table {
|
||||
s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn cell_pos(s: &Session, r: usize, c: usize) -> Pos {
|
||||
let mut path = table_path(s).0;
|
||||
path.extend([r as u32, c as u32, 0]);
|
||||
Pos { story: StoryRef::Body, path: Path(path), off: 0 }
|
||||
}
|
||||
|
||||
/// Type "r{row}c{cell}" into every cell.
|
||||
fn fill(s: &mut Session) {
|
||||
let t = first_table(s);
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for c in 0..row.cells.len() {
|
||||
s.sel = Selection::caret(cell_pos(s, r, c));
|
||||
s.run("text.insert", &json!({"text": format!("r{r}c{c}")})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn para(s: &Session, r: usize, c: usize) -> wordcraft_doc::Paragraph {
|
||||
s.doc.para(StoryRef::Body, &cell_pos(s, r, c).path).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn bold(s: &Session, r: usize, c: usize) -> bool {
|
||||
let p = para(s, r, c);
|
||||
if p.is_empty() { p.mark.bold == Some(true) } else { p.run_ranges().all(|(_, ch)| ch.bold == Some(true)) }
|
||||
}
|
||||
|
||||
fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect {
|
||||
s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn spot_at(s: &mut Session, x: f32, y: f32) -> Option<Spot> {
|
||||
let l = s.layout();
|
||||
target_at(&l, &s.doc, StoryRef::Body, 0, x, y, REACH).map(|t| t.spot)
|
||||
}
|
||||
|
||||
fn mid_y(r: Rect) -> f32 {
|
||||
r.y + r.h / 2.0
|
||||
}
|
||||
|
||||
/// The areas around and in a table: column, row and cell selection areas, the insert buttons
|
||||
/// and the move handle; borders stay the resize module's, the text the caret's.
|
||||
#[test]
|
||||
fn the_areas_of_a_table_are_found() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
let (left, top) = (a.x, a.y);
|
||||
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top - 3.0), Some(Spot::Column(1)));
|
||||
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top + 1.0), Some(Spot::Column(1)));
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
let c = cell_rect(&mut s, 1, 2);
|
||||
assert_eq!(spot_at(&mut s, c.x + 4.5, mid_y(c)), Some(Spot::Cell(1, 2)));
|
||||
assert_eq!(spot_at(&mut s, r1.x + 2.0, mid_y(r1)), Some(Spot::Cell(1, 0)), "the first column's edge has no resize border");
|
||||
assert_eq!(spot_at(&mut s, c.x + 20.0, mid_y(c)), None, "the text is the caret's");
|
||||
assert_eq!(spot_at(&mut s, b.x, mid_y(a)), None, "a column border resizes");
|
||||
assert_eq!(spot_at(&mut s, b.x, top - 8.0), Some(Spot::InsertColumn(1)));
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, a.bottom()), Some(Spot::InsertRow(1)));
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, top), Some(Spot::InsertRow(0)));
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
assert_eq!(spot_at(&mut s, h.x + h.w / 2.0, h.y + h.h / 2.0), Some(Spot::Handle));
|
||||
// Another story's tables aren't in reach.
|
||||
let l = s.layout();
|
||||
assert!(target_at(&l, &s.doc, StoryRef::Part(7), 0, b.x + b.w / 2.0, top - 3.0, REACH).is_none());
|
||||
}
|
||||
|
||||
/// A selected column's text is its own cells', so the status bar and Word Count count only
|
||||
/// them, not the cells between its ends in the text.
|
||||
#[test]
|
||||
fn a_selected_columns_text_is_its_cells() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
|
||||
assert_eq!(s.selected_text(), "r0c1\nr1c1\nr2c1");
|
||||
assert_eq!(s.run("review.wordCount", &json!({})).unwrap()["words"], 3);
|
||||
}
|
||||
|
||||
/// A click above a column selects just its cells: formatting, alignment and Delete leave the
|
||||
/// cells between its ends alone, each one undo step; table commands take the column.
|
||||
#[test]
|
||||
fn a_selected_column_is_edited_cell_by_cell() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
|
||||
assert_eq!(s.column_segments().unwrap().len(), 3);
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
for r in 0..3 {
|
||||
assert!(bold(&s, r, 1), "row {r} of the column is bold");
|
||||
assert!(!bold(&s, r, 0) && !bold(&s, r, 2), "row {r}'s other cells aren't");
|
||||
}
|
||||
s.run("para.alignCenter", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 1, 1).props.align, Some(Align::Center));
|
||||
assert_eq!(para(&s, 0, 2).props.align, None, "the cells between the corners keep theirs");
|
||||
assert!(s.cell_range().is_some(), "formatting keeps the selection");
|
||||
s.run("table.shading", &json!({"color": "FF0000"})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert!(t.rows.iter().all(|row| row.cells[1].props.shading.is_some() && row.cells[0].props.shading.is_none()));
|
||||
let depth = s.undo_depth();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
for r in 0..3 {
|
||||
assert!(para(&s, r, 1).is_empty());
|
||||
assert_eq!(para(&s, r, 0).plain_text(), format!("r{r}c0"));
|
||||
assert_eq!(para(&s, r, 2).plain_text(), format!("r{r}c2"));
|
||||
}
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 2, 1).plain_text(), "r2c1");
|
||||
assert_eq!(s.cell_range().map(|r| r.cols), Some((1, 1)), "undo brings the column selection back");
|
||||
}
|
||||
|
||||
/// Numbering a column numbers its cells as one list, like Word.
|
||||
#[test]
|
||||
fn numbering_a_column_makes_one_list() {
|
||||
let mut s = table_session(3, 2);
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 1, 0));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("para.numbering", &json!({})).unwrap();
|
||||
let nums: Vec<_> = (0..3).map(|r| para(&s, r, 0).props.numbering.map(|n| n.num)).collect();
|
||||
assert!(nums[0].is_some_and(|n| n != 0) && nums.iter().all(|n| *n == nums[0]), "{nums:?}");
|
||||
assert!((0..3).all(|r| para(&s, r, 1).props.numbering.is_none()));
|
||||
}
|
||||
|
||||
/// An empty column's cells take character formatting in their marks; Tab moves on without
|
||||
/// clearing a selected column; a click in place ends a selected empty cell.
|
||||
#[test]
|
||||
fn empty_cells_tab_and_clicks() {
|
||||
let mut s = table_session(2, 2);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
assert!(bold(&s, 0, 0) && bold(&s, 1, 0) && !bold(&s, 0, 1) && !bold(&s, 1, 1));
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 1));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("text.tab", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
|
||||
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
|
||||
// A selected empty cell's corners are one place; clicking there ends the selection.
|
||||
let mut s = table_session(2, 2);
|
||||
let c = cell_rect(&mut s, 1, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": c.x + 4.5, "y": mid_y(c)})).unwrap();
|
||||
assert!(s.cell_range().is_some() && s.sel.is_collapsed());
|
||||
let at = serde_json::to_value(s.sel.focus.clone()).unwrap();
|
||||
s.run("caret.set", &json!({"pos": at})).unwrap();
|
||||
assert!(s.cell_range().is_none());
|
||||
}
|
||||
|
||||
/// Rows, cells and drags: whole rows and cells of one row are an ordinary stream selection;
|
||||
/// a drag over cells of several rows selects their rectangle only.
|
||||
#[test]
|
||||
fn rows_cells_and_drags_select_rectangles() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1)})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((1, 1), (0, 2), true));
|
||||
assert!(s.column_segments().is_none(), "a row is one run of the text");
|
||||
let r2 = cell_rect(&mut s, 2, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1), "toX": r1.x - 10.0, "toY": mid_y(r2) + 50.0})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
|
||||
// From cell (0, 0) to cell (1, 1): four cells, not the row between.
|
||||
let (a, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 1));
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": a.x + 2.0, "y": mid_y(a), "toX": d.x + d.w / 2.0, "toY": mid_y(d)})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 1), (0, 1), false));
|
||||
s.run("format.italic", &json!({})).unwrap();
|
||||
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
|
||||
assert!(italic(&s, 0, 0) && italic(&s, 1, 1) && !italic(&s, 0, 2) && !italic(&s, 1, 2));
|
||||
// Dragging across columns: from the first to the last is every column, all rows.
|
||||
let (c0, c2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": c0.x + 20.0, "y": c0.y - 3.0, "toX": c2.right() + 30.0, "toY": c0.y})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (0, 2), true));
|
||||
// The move handle selects the table.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 2), (0, 2))));
|
||||
// The ribbon's Select Row is the same whole-cell selection.
|
||||
s.sel = Selection::caret(cell_pos(&s, 2, 1));
|
||||
s.run("table.selectRow", &json!({})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
|
||||
}
|
||||
|
||||
/// The insert buttons add a row or column at their border, one undo step, and select it (as
|
||||
/// Word does); typing then goes into its first cell.
|
||||
#[test]
|
||||
fn the_insert_buttons_add_rows_and_columns() {
|
||||
let mut s = table_session(2, 3);
|
||||
fill(&mut s);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(para(&s, 1, 0).is_empty() && para(&s, 2, 0).plain_text() == "r1c0", "the new row is the second");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 1), (0, 2))), "it's selected");
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(para(&s, 1, 0).plain_text(), "x");
|
||||
assert_eq!((first_table(&s).rows.len(), para(&s, 2, 0).plain_text()), (3, "r1c0".to_string()));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s).rows.len(), 2);
|
||||
// Above the first row.
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.y})).unwrap();
|
||||
assert!(para(&s, 0, 0).is_empty() && para(&s, 1, 0).plain_text() == "r0c0");
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 0)));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// A column at the border between the first two, the table keeping its width.
|
||||
let width: f32 = first_table(&s).grid.iter().sum();
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": b.x, "y": a.y - 8.0})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.cols(), 4);
|
||||
assert!(para(&s, 0, 1).is_empty() && para(&s, 0, 2).plain_text() == "r0c1");
|
||||
assert!((t.grid.iter().sum::<f32>() - width).abs() < 0.5);
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (1, 1))), "it's selected");
|
||||
// And at the right edge.
|
||||
let e = cell_rect(&mut s, 0, 3);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": e.right(), "y": a.y - 8.0})).unwrap();
|
||||
assert_eq!(first_table(&s).cols(), 5);
|
||||
assert!(para(&s, 0, 4).is_empty() && para(&s, 0, 3).plain_text() == "r0c2");
|
||||
assert_eq!(s.cell_range().map(|r| r.cols), Some((4, 4)));
|
||||
}
|
||||
|
||||
/// Dragging the move handle floats the table where it's dropped; one undo puts it back; a
|
||||
/// click on the handle moves nothing.
|
||||
#[test]
|
||||
fn dragging_the_handle_moves_the_table() {
|
||||
let mut s = table_session(2, 2);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx, "toY": hy})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_none(), "a click doesn't move it");
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 50.0, "toY": hy + 30.0})).unwrap();
|
||||
let f = first_table(&s).props.float.unwrap();
|
||||
assert_eq!((f.h_rel, f.v_rel, f.overlap), (Anchor::Column, Anchor::Paragraph, true));
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!((moved.x - (a.x + 50.0)).abs() < 0.5 && (moved.y - (a.y + 30.0)).abs() < 0.5, "{a:?} → {moved:?}");
|
||||
// Moving it again starts from where it floats.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx - 20.0, "toY": hy + 10.0})).unwrap();
|
||||
let again = cell_rect(&mut s, 0, 0);
|
||||
assert!((again.x - (moved.x - 20.0)).abs() < 0.5 && (again.y - (moved.y + 10.0)).abs() < 0.5, "{moved:?} → {again:?}");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_none());
|
||||
assert_eq!(cell_rect(&mut s, 0, 0), a);
|
||||
// Nested tables and tables outside the main text stay where they are.
|
||||
let mut nested = table_resize::tables_on(&s.layout(), &s.doc, 0)[0].clone();
|
||||
assert!(movable(&nested));
|
||||
nested.table = Path(vec![0, 0, 0, 1]);
|
||||
assert!(!movable(&nested));
|
||||
}
|
||||
|
||||
/// A double-click on a column border fits the column left of it to its text; as in Word, the
|
||||
/// columns right of it keep their widths and move along, so the table narrows. The right
|
||||
/// edge's column fits the same way.
|
||||
#[test]
|
||||
fn a_double_click_on_a_border_fits_the_column() {
|
||||
let mut s = table_session(1, 3);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("text.insert", &json!({"text": "ab"})).unwrap();
|
||||
let (a, b, c) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
|
||||
let fit = wordcraft_layout::measure_table_columns(&s.doc, &first_table(&s));
|
||||
s.run("table.fitColumn", &json!({"page": 0, "x": a.right(), "y": mid_y(a)})).unwrap();
|
||||
let (a1, b1, c1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
|
||||
assert!((a1.w - fit[0].1).abs() < 0.5 && a1.w < a.w - 50.0, "{a:?} → {a1:?}, {fit:?}");
|
||||
assert!((b1.w - b.w).abs() < 0.5 && (c1.w - c.w).abs() < 0.5, "the columns right of it keep their widths");
|
||||
assert!((b1.x - b.x - (a1.w - a.w)).abs() < 0.5 && (c1.right() - c.right() - (a1.w - a.w)).abs() < 0.5, "and move along");
|
||||
s.run("table.fitColumn", &json!({"page": 0, "x": c1.right(), "y": mid_y(c1)})).unwrap();
|
||||
let c2 = cell_rect(&mut s, 0, 2);
|
||||
assert!((c2.w - fit[2].1).abs() < 0.5 && c2.w < c.w, "{c:?} → {c2:?}");
|
||||
}
|
||||
|
||||
/// Pasting formatting onto a selected column leaves its text (it used to clear it).
|
||||
#[test]
|
||||
fn the_format_painter_keeps_a_selected_columns_text() {
|
||||
let mut s = table_session(2, 2);
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("edit.formatPainter", &json!({})).unwrap();
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 1));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("edit.pasteFormat", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
|
||||
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
|
||||
}
|
||||
|
||||
/// Where the app extends the selection while dragging (`caret.set` with `extend`).
|
||||
fn drag_to(s: &mut Session, to: Pos) {
|
||||
s.run("caret.set", &json!({"pos": to, "extend": true})).unwrap();
|
||||
}
|
||||
|
||||
/// An ordinary drag from one cell into another selects the rectangle of whole cells between
|
||||
/// them, so bold takes just those, as in Word; back in the first cell it's text again, from
|
||||
/// where the drag started. Out of a nested table, the cells are the table's around it.
|
||||
#[test]
|
||||
fn a_drag_across_cells_selects_their_rectangle() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let start = Pos { off: 1, ..cell_pos(&s, 0, 1) };
|
||||
s.run("caret.set", &json!({"pos": start})).unwrap();
|
||||
let down = Pos { off: 2, ..cell_pos(&s, 2, 1) };
|
||||
drag_to(&mut s, down);
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
for r in 0..3 {
|
||||
assert!(bold(&s, r, 1) && !bold(&s, r, 0) && !bold(&s, r, 2), "row {r}: just the dragged column");
|
||||
}
|
||||
// On to the left: the rectangle still runs from the cell the drag started in.
|
||||
let left = cell_pos(&s, 1, 0);
|
||||
drag_to(&mut s, left);
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
|
||||
// Back in that cell: its text, from where the drag started.
|
||||
let back = Pos { off: 3, ..cell_pos(&s, 0, 1) };
|
||||
drag_to(&mut s, back.clone());
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel, Selection { anchor: start, focus: back });
|
||||
// From a nested table's cell into another cell of the table around it.
|
||||
let mut s = table_session(2, 2);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
assert_eq!(s.sel.focus.path.0.len(), 7, "the caret is in the nested table");
|
||||
let to = cell_pos(&s, 1, 1);
|
||||
drag_to(&mut s, to);
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.table, range.rows, range.cols), (table_path(&s), (0, 1), (0, 1)));
|
||||
}
|
||||
|
||||
/// Shift+Down from a cell takes it and the cell below, whole; on past the table, the anchor's
|
||||
/// row and every row after it, then the text; Shift+Up comes back to the cells, then into the
|
||||
/// first cell's text. Shift+click goes on from where the selection started, and from text
|
||||
/// before the table a drag into a cell takes the rows down to it, whole.
|
||||
#[test]
|
||||
fn shift_arrows_and_clicks_select_cells_then_rows() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("text.insert", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap();
|
||||
fill(&mut s);
|
||||
let after = table_path(&s).last() + 1;
|
||||
let shift = |s: &mut Session, id: &str| {
|
||||
s.run(id, &json!({"extend": true})).unwrap();
|
||||
};
|
||||
s.sel = Selection::caret(cell_pos(&s, 1, 1));
|
||||
shift(&mut s, "caret.down");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
|
||||
shift(&mut s, "caret.down");
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 0), "from the start of the anchor's row");
|
||||
assert_eq!(s.sel.focus.path, Path::top(after));
|
||||
s.run("format.italic", &json!({})).unwrap();
|
||||
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
|
||||
assert!(italic(&s, 1, 0) && italic(&s, 2, 2) && !italic(&s, 0, 1), "whole rows from the anchor's");
|
||||
shift(&mut s, "caret.up");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
|
||||
shift(&mut s, "caret.up");
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 1));
|
||||
assert_eq!(s.sel.focus.path, cell_pos(&s, 1, 1).path);
|
||||
// Shift+click: the rectangle from the anchor's cell.
|
||||
let corner = cell_pos(&s, 0, 0);
|
||||
s.run("caret.set", &json!({"pos": corner, "extend": true})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
|
||||
// From the text before the table into a cell: the rows down to that cell's, whole.
|
||||
s.sel = Selection::caret(Pos::body(0, 2));
|
||||
let into = cell_pos(&s, 1, 1);
|
||||
drag_to(&mut s, into);
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel, Selection { anchor: Pos::body(0, 2), focus: Pos { off: 4, ..cell_pos(&s, 1, 2) } });
|
||||
}
|
||||
|
||||
/// Clicks away from the areas, and hostile numbers: an error and no change, never a panic.
|
||||
#[test]
|
||||
fn misses_and_hostile_params_change_nothing() {
|
||||
let mut s = table_session(2, 2);
|
||||
let start = first_table(&s);
|
||||
let c = cell_rect(&mut s, 1, 1);
|
||||
let mid = json!({"page": 0, "x": c.x + c.w / 2.0, "y": mid_y(c), "toX": 1, "toY": 1});
|
||||
for id in ["table.selectAt", "table.insertAt", "table.moveDrag", "table.fitColumn"] {
|
||||
assert!(s.run(id, &mid).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({"page": 99, "x": 1, "y": 1, "toX": 1, "toY": 1})).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({"page": 0, "x": 1e30, "y": -1e30, "toX": 1e30, "toY": 1e30, "reach": 1e30})).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({})).is_err(), "{id}");
|
||||
}
|
||||
// An insert button isn't a selection area, nor a selection area a button.
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
assert!(s.run("table.selectAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).is_err());
|
||||
assert!(s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": mid_y(a)})).is_err());
|
||||
assert_eq!(first_table(&s), start);
|
||||
// A drag to the ends of the earth keeps the table on its page.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0, "toX": 3e38, "toY": -3e38})).unwrap();
|
||||
let f = first_table(&s).props.float.unwrap();
|
||||
assert!(f.x.is_finite() && f.y.is_finite());
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user