mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36af036233 | ||
|
|
f0d47b6090 | ||
|
|
f24a5b22b2 | ||
|
|
284818b467 | ||
|
|
994162fdff | ||
|
|
d863811511 | ||
|
|
681a57fc1a | ||
|
|
8ca285ca9d | ||
|
|
3ede32e80e | ||
|
|
97ade895d7 | ||
|
|
05e5ff0e0d | ||
|
|
672522b2ed | ||
|
|
55bfc33418 | ||
|
|
5383d80f75 | ||
|
|
8c67ef7c28 | ||
|
|
fc52079f58 | ||
|
|
c9079196db | ||
|
|
7d37b14679 | ||
|
|
5aa8151a53 | ||
|
|
74548cff57 | ||
|
|
9f0776a70b | ||
|
|
541e10e23a | ||
|
|
2f833b6acf | ||
|
|
2e66cbb261 | ||
|
|
99561097d5 | ||
|
|
45abb5ded3 | ||
|
|
906e0f8aca | ||
|
|
64f656a8bc | ||
|
|
6f35313f68 | ||
|
|
1677c2d9cf | ||
|
|
26cdcfd4c6 | ||
|
|
fccc09e4c9 | ||
|
|
90515abd1e | ||
|
|
0acf6bd512 | ||
|
|
912d49f38f | ||
|
|
da5da66e51 | ||
|
|
bc60cbaadb | ||
|
|
7fbcd0643a | ||
|
|
e347ef203e | ||
|
|
0a82b684f8 | ||
|
|
03e9328d4b | ||
|
|
9132d1bd40 | ||
|
|
00ed50515e | ||
|
|
411ce31de7 | ||
|
|
18e25d0883 | ||
|
|
e619b12ceb | ||
|
|
f7ce977e4a | ||
|
|
1e53adf9d6 | ||
|
|
d8940cb317 | ||
|
|
2f9bf4e800 | ||
|
|
417896301b | ||
|
|
b30cda1535 | ||
|
|
64319e0039 | ||
|
|
691e887004 | ||
|
|
325e5a6157 | ||
|
|
e75ff36251 | ||
|
|
62a17b841f | ||
|
|
e22ce1d05e | ||
|
|
feaff0f22b | ||
|
|
241877d666 | ||
|
|
7cccdf0f38 | ||
|
|
af622ac843 | ||
|
|
4a1acb6c3f | ||
|
|
3c8840556a | ||
|
|
c1e1c06297 | ||
|
|
c19a2aebef | ||
|
|
d4d28e01f4 | ||
|
|
b7aa7639a9 | ||
|
|
dd66d5171b | ||
|
|
d510d9a41a | ||
|
|
956963e9d9 | ||
|
|
de8a3daf31 | ||
|
|
25048c8b9c | ||
|
|
439cfd2fe9 | ||
|
|
59b04d7644 | ||
|
|
6cf0aec0ff | ||
|
|
2f58d441b6 | ||
|
|
1c188ba140 | ||
|
|
326d695335 | ||
|
|
715f85c612 | ||
|
|
a38caa4b15 | ||
|
|
1d0dff03a4 | ||
|
|
9644e646a6 | ||
|
|
8889e356d5 | ||
|
|
98c457a98d |
@@ -0,0 +1,73 @@
|
||||
# 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
|
||||
@@ -12,6 +12,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `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/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") |
|
||||
@@ -70,3 +71,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
|
||||
|
||||
Generated
+130
-24
@@ -241,6 +241,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -842,6 +843,22 @@ 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"
|
||||
@@ -1315,6 +1332,12 @@ 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"
|
||||
@@ -1352,6 +1375,12 @@ 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"
|
||||
@@ -1539,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1555,7 +1584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1703,6 +1732,15 @@ 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"
|
||||
@@ -1711,7 +1749,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1720,7 +1758,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2399,6 +2437,15 @@ 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"
|
||||
@@ -2796,6 +2843,16 @@ 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"
|
||||
@@ -2856,6 +2913,17 @@ 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"
|
||||
@@ -3795,7 +3863,10 @@ 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]]
|
||||
@@ -3996,6 +4067,17 @@ 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"
|
||||
@@ -4996,11 +5078,31 @@ 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.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5014,18 +5116,21 @@ dependencies = [
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"wl-clipboard-rs",
|
||||
"wordcraft-control-key",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-ui-egui",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
@@ -5037,11 +5142,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -5053,7 +5158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
@@ -5068,7 +5173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -5090,7 +5195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5116,7 +5221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5129,7 +5234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5142,14 +5247,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5168,7 +5273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5180,7 +5285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5188,7 +5293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -5208,7 +5313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5217,7 +5322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5231,8 +5336,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
@@ -5255,7 +5361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5274,7 +5380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5387,7 +5493,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -73,7 +73,7 @@ egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -26,6 +26,32 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
|
||||
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
|
||||
unmodified and used under the MIT License; the copyright and permission notice is in the file.
|
||||
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
|
||||
package, bundled unmodified and used under the BSD 3-Clause License:
|
||||
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
2024 Leonardo Araujo and Aline Benevides
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are
|
||||
permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright notice, this list of
|
||||
conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of
|
||||
conditions and the following disclaimer in the documentation and/or other materials
|
||||
provided with the distribution.
|
||||
* Neither the name of the University of Campinas, of the University of Minho nor the names
|
||||
of its contributors may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
|
||||
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO
|
||||
J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
|
||||
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- 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, …)
|
||||
|
||||
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français 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,6 +151,7 @@ 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
|
||||
```
|
||||
@@ -234,6 +235,10 @@ 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).
|
||||
@@ -267,17 +272,23 @@ 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 |
|
||||
|
||||
+73
-6
@@ -2,7 +2,9 @@
|
||||
|
||||
**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-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously trivial: independent complex-script formatting preserved across DOCX open/save; 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
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
@@ -14,7 +16,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **483 / 498 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **485 / 500 (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) |
|
||||
@@ -102,7 +104,7 @@ 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 | none | 0% |
|
||||
| French | fr | partial (interface) | ~95% |
|
||||
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | partial | ~95% |
|
||||
@@ -110,9 +112,10 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| 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%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included.
|
||||
Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
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
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -131,12 +134,51 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-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-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 | 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 |
|
||||
@@ -148,6 +190,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
|
||||
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
|
||||
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
|
||||
| 2026-10-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 |
|
||||
@@ -179,6 +222,7 @@ 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 |
|
||||
@@ -190,8 +234,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-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 |
|
||||
@@ -212,13 +259,29 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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-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-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) |
|
||||
@@ -237,6 +300,7 @@ 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%) |
|
||||
@@ -248,7 +312,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-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%) |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! 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,6 +1,8 @@
|
||||
//! `wordcraft-cli`: WordCraft from the command line.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
@@ -25,8 +27,14 @@ 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]
|
||||
MCP server on stdio (headless, or bridged to the app)
|
||||
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 zotero <command> <file> [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P]
|
||||
run a Zotero command on a document (Zotero must be
|
||||
running): addEditCitation, addEditBibliography,
|
||||
@@ -76,6 +84,26 @@ 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 }];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
|
||||
@@ -265,9 +293,17 @@ 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 backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
|
||||
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 {
|
||||
// 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());
|
||||
@@ -281,6 +317,13 @@ 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)?;
|
||||
}
|
||||
@@ -428,4 +471,58 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
+186
-15
@@ -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;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
@@ -27,7 +27,8 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
if let Err(e) = guard_unload(dirty.clone()) {
|
||||
let saves = Rc::new(PickerSaves::default());
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -50,10 +51,20 @@ pub fn start() {
|
||||
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
|
||||
log::error!("pasting pictures is unavailable: {e}");
|
||||
}
|
||||
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));
|
||||
// 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()));
|
||||
app.autosave = false;
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
// 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 }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -75,6 +86,38 @@ 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 {
|
||||
@@ -94,6 +137,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.settle_picker_saves();
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
@@ -153,8 +197,9 @@ 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>>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> 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();
|
||||
@@ -179,7 +224,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services
|
||||
})),
|
||||
// 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(download)),
|
||||
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
on_dirty: Some(Box::new(move |d| dirty.set(d))),
|
||||
@@ -200,10 +245,11 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
||||
|
||||
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
|
||||
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> 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| {
|
||||
if dirty.get() {
|
||||
// A save still in the browser's picker hasn't written anything yet.
|
||||
if dirty.get() || saves.pending.get() > 0 {
|
||||
e.prevent_default();
|
||||
// Older browsers ask only when `returnValue` is set.
|
||||
e.set_return_value("unsaved changes");
|
||||
@@ -215,20 +261,145 @@ fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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:?}");
|
||||
/// 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> {
|
||||
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")?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
|
||||
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)?;
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 }
|
||||
@@ -43,18 +44,30 @@ objc2 = "0.6"
|
||||
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.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
# 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.
|
||||
@@ -64,6 +77,3 @@ 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"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 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.
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
|
||||
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
|
||||
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
|
||||
/// are ignored, as the XDG Base Directory spec says.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
#[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();
|
||||
@@ -130,6 +130,7 @@ mod xdg {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
@@ -163,6 +164,7 @@ mod tests {
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
let p = |s: &str| PathBuf::from(s);
|
||||
|
||||
@@ -20,18 +20,28 @@ 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_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 {
|
||||
/// 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 {
|
||||
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
|
||||
@@ -94,9 +104,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
backends
|
||||
}
|
||||
None => {
|
||||
let adapters = probe_dx12(options);
|
||||
let backends = choose_backends(None, &adapters);
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
// 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");
|
||||
}
|
||||
backends
|
||||
}
|
||||
};
|
||||
@@ -157,7 +174,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)), Backends::DX12);
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -165,8 +182,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), &[]), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &[], true), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
|
||||
assert_eq!(configured_backends(backends), backends);
|
||||
}
|
||||
}
|
||||
@@ -192,13 +209,23 @@ 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), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[]), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[], true), 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), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &weak_gpu, true), 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), Backends::DX12);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-25
@@ -12,6 +12,7 @@
|
||||
mod animations;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
mod default_apps;
|
||||
mod file_dialogs;
|
||||
@@ -19,11 +20,11 @@ mod file_dialogs;
|
||||
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},
|
||||
};
|
||||
|
||||
@@ -53,7 +54,7 @@ impl eframe::App for App {
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if closing || self.0.quit_requested {
|
||||
} 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.
|
||||
@@ -77,6 +78,7 @@ 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();
|
||||
@@ -94,11 +96,16 @@ impl eframe::App for App {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
}
|
||||
static QUIT: AtomicBool = AtomicBool::new(false);
|
||||
if QUIT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
@@ -123,6 +130,21 @@ 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()
|
||||
@@ -177,34 +199,25 @@ fn save_prefs(app: &WordApp) {
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the
|
||||
/// system's animation setting (`animations`), and making WordCraft the default app for Word
|
||||
/// documents (`default_apps`, #295).
|
||||
/// 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 {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
print: Some(print::hook()),
|
||||
clipboard_picture: Some(Box::new(clipboard_picture)),
|
||||
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(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..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")]
|
||||
@@ -268,8 +281,8 @@ fn main() -> eframe::Result {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
|
||||
// DirectX 12 has no GPU driver, and compile DirectX 12 shaders with FXC, which needs no
|
||||
// DLL of ours (#603; see graphics.rs).
|
||||
// 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).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
load_user_fonts();
|
||||
@@ -277,6 +290,16 @@ fn main() -> eframe::Result {
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -287,10 +310,22 @@ fn main() -> eframe::Result {
|
||||
"WordCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
// 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 (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &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));
|
||||
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");
|
||||
@@ -301,6 +336,12 @@ 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}");
|
||||
|
||||
+63
-216
@@ -1,229 +1,76 @@
|
||||
//! 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
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
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
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// 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());
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
receiver
|
||||
})
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
});
|
||||
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 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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
% 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
|
||||
}
|
||||
@@ -51,14 +51,22 @@ sync_github_name = false
|
||||
[people.henno]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GhostMitra]
|
||||
[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]
|
||||
sync_github_name = true
|
||||
[people.manucarcor]
|
||||
real_name = "Manuel Carmona"
|
||||
display_name = "manucarcor"
|
||||
sync_github_name = false
|
||||
|
||||
[people.odedigo]
|
||||
|
||||
@@ -37,6 +37,26 @@ 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::*;
|
||||
@@ -56,5 +76,11 @@ 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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! 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,4 +1,4 @@
|
||||
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt
|
||||
//! Charts, SmartArt diagrams and native ink: 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 or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt
|
||||
/// A chart, SmartArt diagram or native ink preview, 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 chart or diagram itself back (not
|
||||
/// Where the graphic was read from, so saving writes the original drawing itself back (not
|
||||
/// only these items). `None` for graphics made some other way.
|
||||
#[serde(skip)]
|
||||
pub source: Option<std::sync::Arc<Embedded>>,
|
||||
@@ -44,7 +44,8 @@ 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 or SmartArt diagram, the `w:object` of an OLE object).
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
/// or 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>,
|
||||
@@ -68,6 +69,8 @@ pub enum GraphicKind {
|
||||
#[default]
|
||||
Chart,
|
||||
Diagram,
|
||||
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
|
||||
Ink,
|
||||
}
|
||||
|
||||
impl GraphicKind {
|
||||
@@ -76,6 +79,7 @@ impl GraphicKind {
|
||||
match self {
|
||||
GraphicKind::Chart => "chart",
|
||||
GraphicKind::Diagram => "diagram",
|
||||
GraphicKind::Ink => "ink",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod bidi;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
pub mod collate;
|
||||
pub mod connector;
|
||||
pub mod control;
|
||||
pub mod edit;
|
||||
@@ -24,6 +25,7 @@ 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;
|
||||
@@ -136,6 +138,11 @@ 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
|
||||
@@ -274,12 +281,28 @@ 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); `None` is Word's defaults.
|
||||
pub inset: Option<[u32; 4]>,
|
||||
/// Resize shape to fit text (`a:spAutoFit`): 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).
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
+48
-18
@@ -303,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` = the same as `size` (see [`CharProps::overlay`]).
|
||||
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size_cs: Option<f32>,
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`.
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold_cs: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -390,11 +390,8 @@ impl CharProps {
|
||||
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
///
|
||||
/// 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).
|
||||
/// Plain and complex-script properties inherit independently. Commands that intend to
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, 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,
|
||||
@@ -402,15 +399,6 @@ 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);
|
||||
@@ -638,6 +626,33 @@ 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")]
|
||||
@@ -671,6 +686,13 @@ 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.
|
||||
@@ -709,6 +731,10 @@ 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.
|
||||
@@ -743,9 +769,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, 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, space_before_auto, space_after_auto, line_spacing,
|
||||
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, 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 {
|
||||
@@ -1025,6 +1051,10 @@ 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>>>,
|
||||
|
||||
@@ -8,6 +8,10 @@ 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")]
|
||||
@@ -32,6 +36,9 @@ 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,
|
||||
@@ -82,6 +89,7 @@ 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),
|
||||
@@ -108,6 +116,11 @@ 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)
|
||||
@@ -138,6 +151,12 @@ 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.
|
||||
@@ -153,6 +172,9 @@ 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.
|
||||
@@ -206,8 +228,10 @@ impl ResolvedPara {
|
||||
indent_left: clampi(p.indent_left),
|
||||
indent_right: clampi(p.indent_right),
|
||||
indent_first: clampi(p.indent_first),
|
||||
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),
|
||||
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),
|
||||
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 }),
|
||||
@@ -226,6 +250,7 @@ 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(0.0), lang: Some("en-US".into()), ..chr() },
|
||||
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(1.0), lang: Some("en-US".into()), ..chr() },
|
||||
default_para: ParaProps {
|
||||
space_after: Some(8.0),
|
||||
space_before: Some(0.0),
|
||||
@@ -444,6 +444,19 @@ 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,7 +1,7 @@
|
||||
//! Sprms → model properties, and style-chain resolution.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
|
||||
|
||||
use crate::sprm::{self, Prl, Toggle};
|
||||
@@ -187,11 +187,63 @@ 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),
|
||||
|
||||
@@ -142,6 +142,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
notes: &[],
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
@@ -215,7 +216,9 @@ 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 ctx = WalkCtx { notes: ¬e_links, marks: &marks, ..ctx };
|
||||
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 };
|
||||
|
||||
// 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.
|
||||
@@ -277,6 +280,9 @@ struct WalkCtx<'a> {
|
||||
marks: &'a [(u32, notes::Mark)],
|
||||
/// Floating shapes and pictures by anchor CP (absolute), sorted.
|
||||
drawn: &'a [(u32, InlineObject)],
|
||||
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
|
||||
/// each ends the section's last paragraph, it isn't a page break inside it.
|
||||
sect_ends: &'a [u32],
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -350,8 +356,10 @@ 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}' => {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
// 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);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 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 ([MS-DOC] §2.6.29 — handled in a later pass).
|
||||
const P_CHG_TABS: u16 = 0xC615;
|
||||
/// `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;
|
||||
/// `sprmTDefTable`: table cell borders and widths.
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
|
||||
@@ -32,7 +34,16 @@ 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) {
|
||||
(_, P_CHG_TABS) => after.get(..2).map(|b| 2 + u16::from_le_bytes([b[0], b[1]]) as usize),
|
||||
// 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,
|
||||
},
|
||||
// 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),
|
||||
|
||||
@@ -1803,3 +1803,106 @@ 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"]);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod rt {
|
||||
pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
|
||||
pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
|
||||
pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate";
|
||||
pub const FONT_TABLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
|
||||
pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
|
||||
pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
|
||||
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! 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).
|
||||
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
|
||||
//! are kept the same way.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -73,7 +74,7 @@ impl Reader<'_> {
|
||||
|
||||
/// Keep part `root` and every part it reaches through its relationships. False when `root`
|
||||
/// itself can't be kept.
|
||||
fn keep_part_tree(&mut self, root: &str) -> bool {
|
||||
pub(super) fn keep_part_tree(&mut self, root: &str) -> bool {
|
||||
if self.embedded.parts.contains_key(root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -219,11 +219,9 @@ 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" => {
|
||||
|
||||
@@ -161,6 +161,10 @@ 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);
|
||||
}
|
||||
@@ -229,8 +233,9 @@ 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);
|
||||
}
|
||||
@@ -385,6 +390,23 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
|
||||
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
|
||||
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
|
||||
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
|
||||
/// those runs so they look the same here; the links stay.
|
||||
fn unstyle_toc_links(doc: &mut Document) {
|
||||
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
|
||||
if is_toc(&f.instr) {
|
||||
let _ = doc.format_range(&f.start, &f.end, &|c| {
|
||||
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") {
|
||||
c.style = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize only a complete TOC range that the engine's heading/entry representation can
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
fn toc_field_to_heading(body: &mut Blocks) {
|
||||
@@ -770,6 +792,25 @@ 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(())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,28 @@ 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.
|
||||
@@ -33,6 +55,27 @@ 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())
|
||||
@@ -189,22 +232,12 @@ 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() {
|
||||
@@ -229,13 +262,21 @@ 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 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 });
|
||||
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) => {}
|
||||
}
|
||||
}
|
||||
"w:pBdr" => p.borders = Some(borders(k)),
|
||||
@@ -251,6 +292,14 @@ 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();
|
||||
@@ -489,6 +538,7 @@ 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" => {
|
||||
|
||||
@@ -14,11 +14,14 @@ 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::{Rels, rt};
|
||||
use crate::package::{ContentTypes, Rels, rt};
|
||||
use crate::units::{int, measure, u32_of};
|
||||
use crate::xml::El;
|
||||
use crate::xml::{El, Node};
|
||||
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.
|
||||
@@ -163,7 +166,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", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "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"))
|
||||
}
|
||||
@@ -331,7 +334,8 @@ impl Reader<'_> {
|
||||
pb.push_obj(o, &CharProps::default());
|
||||
}
|
||||
if let Some(ppr) = e.child("w:pPr") {
|
||||
let (props, mark) = self.pc.ppr(ppr);
|
||||
let (mut props, mark) = self.pc.ppr(ppr);
|
||||
super::props::resolve_level_only(&self.doc.styles, &mut props, "Normal");
|
||||
para.props = props;
|
||||
para.mark = mark;
|
||||
// A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del`
|
||||
@@ -353,7 +357,14 @@ 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| self.pc.ppr(o).0.formatting()).unwrap_or_default();
|
||||
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();
|
||||
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if para.props.numbering.is_some()
|
||||
@@ -500,7 +511,10 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
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) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -661,7 +675,9 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
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) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
|
||||
}
|
||||
@@ -881,6 +897,25 @@ 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") {
|
||||
@@ -892,7 +927,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 => Vec::new(),
|
||||
GraphicKind::Chart | GraphicKind::Ink => 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));
|
||||
@@ -909,17 +944,14 @@ impl Reader<'_> {
|
||||
} else {
|
||||
let wsp = gd.find("wps:wsp")?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// 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)
|
||||
// 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)
|
||||
{
|
||||
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));
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
}
|
||||
obj
|
||||
};
|
||||
@@ -938,6 +970,34 @@ 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)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -946,6 +1006,7 @@ 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());
|
||||
@@ -978,6 +1039,7 @@ impl Reader<'_> {
|
||||
None => Vec::new(),
|
||||
},
|
||||
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
|
||||
GraphicKind::Ink => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,6 +1222,15 @@ 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.
|
||||
@@ -1179,7 +1250,13 @@ 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);
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra }
|
||||
// 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 }
|
||||
}
|
||||
|
||||
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
|
||||
@@ -1649,7 +1726,9 @@ 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| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() };
|
||||
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 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,4 +1,4 @@
|
||||
//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`).
|
||||
//! Writing back charts, SmartArt diagrams, native ink 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
|
||||
@@ -16,7 +16,7 @@ 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::emu;
|
||||
use crate::units::{MAX_LEN_PT, emu, int};
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
|
||||
/// Most bytes written for copies of parts (objects pasted more than once).
|
||||
@@ -29,6 +29,9 @@ 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,
|
||||
@@ -90,6 +93,14 @@ 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 {
|
||||
@@ -251,6 +262,12 @@ 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;
|
||||
@@ -305,7 +322,7 @@ impl super::Writer<'_> {
|
||||
if let Some((w, h, float, alt)) = resize {
|
||||
self.resize_object(&mut root, w, h, float, alt, 0);
|
||||
}
|
||||
Some(root.to_xml())
|
||||
Some(root)
|
||||
}
|
||||
|
||||
/// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points:
|
||||
@@ -437,6 +454,51 @@ 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.
|
||||
@@ -527,9 +589,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();
|
||||
|
||||
@@ -368,6 +368,8 @@ 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 {
|
||||
|
||||
@@ -26,9 +26,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
if let Some(s) = &c.style {
|
||||
w.val("w:rStyle", s);
|
||||
}
|
||||
// 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.
|
||||
// Emit only explicit properties so absent values keep inheriting from the style chain.
|
||||
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())]);
|
||||
@@ -43,9 +41,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.or(c.bold));
|
||||
toggle(w, "w:bCs", c.bold_cs);
|
||||
toggle(w, "w:i", c.italic);
|
||||
toggle(w, "w:iCs", c.italic_cs.or(c.italic));
|
||||
toggle(w, "w:iCs", c.italic_cs);
|
||||
toggle(w, "w:caps", c.caps);
|
||||
toggle(w, "w:smallCaps", c.small_caps);
|
||||
toggle(w, "w:strike", c.strike);
|
||||
@@ -77,7 +75,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.or(c.size) {
|
||||
if let Some(s) = c.size_cs {
|
||||
w.val("w:szCs", &half(s));
|
||||
}
|
||||
if let Some(h) = c.highlight {
|
||||
@@ -211,6 +209,35 @@ 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 {
|
||||
@@ -268,7 +295,12 @@ 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() {
|
||||
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()
|
||||
{
|
||||
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 {
|
||||
@@ -280,9 +312,15 @@ 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));
|
||||
@@ -499,6 +537,9 @@ 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.
|
||||
|
||||
@@ -524,38 +524,50 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
// 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.
|
||||
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
|
||||
// ink keep their original source and related parts.
|
||||
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
|
||||
let 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 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,
|
||||
};
|
||||
if inner.is_empty() {
|
||||
let Some(mut root) = root else { return };
|
||||
if 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() {
|
||||
return;
|
||||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
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(&inner);
|
||||
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.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
@@ -1019,13 +1031,25 @@ impl Writer<'_> {
|
||||
if body.anchor != wordcraft_doc::props::VAlign::Top {
|
||||
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
|
||||
}
|
||||
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");
|
||||
// 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));
|
||||
}
|
||||
None => w.empty("wps:bodyPr", &attrs),
|
||||
}
|
||||
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");
|
||||
}
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
||||
+50
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::name::{QName, ResolveResult};
|
||||
use quick_xml::reader::NsReader;
|
||||
|
||||
use crate::DocxError;
|
||||
@@ -26,6 +26,7 @@ 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"),
|
||||
@@ -81,6 +82,24 @@ 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),
|
||||
@@ -288,6 +307,28 @@ 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(_)) {
|
||||
@@ -489,6 +530,14 @@ 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)));
|
||||
// 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)));
|
||||
// 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)));
|
||||
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,3 +53,30 @@ 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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 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);
|
||||
});
|
||||
}
|
||||
@@ -1146,7 +1146,8 @@ 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:bCs/><w:rPrChange w:id="#), "{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}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(again.body, d.body);
|
||||
assert_eq!(again.last_section, d.last_section);
|
||||
@@ -1512,3 +1513,421 @@ 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());
|
||||
}
|
||||
|
||||
#[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 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()));
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ 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),
|
||||
@@ -163,6 +165,20 @@ 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),
|
||||
|
||||
@@ -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, next: None });
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
|
||||
let b = text_box(&mut d, "", TextBody::default());
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
|
||||
for (i, o) in [a, b, c].into_iter().enumerate() {
|
||||
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -30,6 +31,7 @@ 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
|
||||
@@ -355,6 +357,7 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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};
|
||||
@@ -563,7 +564,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), &tag);
|
||||
let n = number_of(&s.doc, &bib_order(&s.doc, st, super::default_lang(s)), &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}") };
|
||||
@@ -611,7 +612,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) -> Vec<usize> {
|
||||
fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) -> Vec<usize> {
|
||||
let cited = cited_tags(doc);
|
||||
let mut idx: Vec<usize> = if cited.is_empty() {
|
||||
(0..doc.sources.len()).collect()
|
||||
@@ -621,8 +622,10 @@ fn bib_order(doc: &Document, st: &CiteStyle) -> 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()
|
||||
};
|
||||
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();
|
||||
// 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())
|
||||
};
|
||||
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)),
|
||||
@@ -645,7 +648,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);
|
||||
let order = bib_order(&s.doc, st, super::default_lang(s));
|
||||
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
|
||||
@@ -763,7 +766,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)
|
||||
return bib_order(&s.doc, st, super::default_lang(s))
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered())))
|
||||
@@ -824,21 +827,22 @@ 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 mut grouped: std::collections::BTreeMap<String, Vec<u32>> = Default::default();
|
||||
let lang = super::default_lang(s);
|
||||
let mut grouped: std::collections::BTreeMap<(SortKey, String), Vec<u32>> = Default::default();
|
||||
for (e, p) in with_pages {
|
||||
let pages = grouped.entry(e).or_default();
|
||||
let pages = grouped.entry((sort_key(&e, lang.as_deref()), 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 = e.chars().next().map(|c| c.to_ascii_uppercase());
|
||||
for ((_, e), pages) in grouped {
|
||||
let first = heading_letter(&e, lang.as_deref());
|
||||
if kind.starts_with("INDEX") && first != letter {
|
||||
letter = first;
|
||||
letter = first.clone();
|
||||
if let Some(l) = first {
|
||||
out.push((l.to_string(), false));
|
||||
out.push((l, false));
|
||||
}
|
||||
}
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! 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;
|
||||
@@ -94,7 +98,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 });
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
|
||||
s.pending = None;
|
||||
}
|
||||
|
||||
@@ -116,6 +120,11 @@ 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() {
|
||||
@@ -132,8 +141,25 @@ 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)?;
|
||||
@@ -146,7 +172,8 @@ 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),
|
||||
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
|
||||
// Pasting formatting (the format painter) leaves the text alone.
|
||||
_ if mutates && (id.starts_with("text.") || (id.starts_with("edit.paste") && id != "edit.pasteFormat")) => {
|
||||
delete(s, &segs)?;
|
||||
run(s, v)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,26 @@ 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 }
|
||||
}
|
||||
@@ -22,6 +42,14 @@ 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()));
|
||||
@@ -33,11 +61,12 @@ 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}"#),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: 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_text).params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge)
|
||||
.params(r#"{"text"?: string, "html"?: string, "rtf"?: 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;
|
||||
@@ -133,14 +162,18 @@ 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 {
|
||||
// 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())),
|
||||
};
|
||||
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);
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -284,6 +317,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -119,9 +119,10 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"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).
|
||||
_ => {
|
||||
// A blank document is written in the editing language (the templates' text is English).
|
||||
let mut d = Document::new();
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = &s.editing_lang {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
@@ -233,8 +234,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
|
||||
let png = img.to_png();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,8 @@ fn font_json(f: &UserFont) -> Value {
|
||||
/// 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> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
if !meta.is_file() {
|
||||
return Err(CmdError::Failed(format!("{path}: not a file")));
|
||||
}
|
||||
if meta.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES as u64 {
|
||||
return Err(CmdError::Failed(format!("{path}: the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
|
||||
// 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 {
|
||||
|
||||
@@ -15,15 +15,14 @@ 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| {
|
||||
// Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs`
|
||||
// follows `bold`.
|
||||
// The button formats both plain and complex-script text explicitly.
|
||||
toggle(
|
||||
s,
|
||||
v,
|
||||
|r| r.bold,
|
||||
|c, on| {
|
||||
c.bold = Some(on);
|
||||
c.bold_cs = None;
|
||||
c.bold_cs = Some(on);
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -36,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|r| r.italic,
|
||||
|c, on| {
|
||||
c.italic = Some(on);
|
||||
c.italic_cs = None;
|
||||
c.italic_cs = Some(on);
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -168,13 +167,15 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active.
|
||||
/// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph:
|
||||
/// an empty one's mark takes the formatting.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
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 && a.path == b.path && a.story == b.story)
|
||||
.filter(|(a, b)| (a != b || cells) && 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.
|
||||
@@ -187,6 +188,10 @@ 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);
|
||||
}
|
||||
@@ -217,6 +222,10 @@ 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));
|
||||
@@ -319,7 +328,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 = None;
|
||||
c.size_cs = Some(sz);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,7 +347,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 = None;
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,7 +355,7 @@ 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 = None;
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -568,7 +577,12 @@ fn char_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
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);
|
||||
apply(s, &|c| c.overlay(&props))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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;
|
||||
@@ -167,14 +168,15 @@ struct PageRef {
|
||||
|
||||
#[derive(Default)]
|
||||
struct Node {
|
||||
/// The entry's levels as written (the key is their lower-case form).
|
||||
/// The entry's levels as written (the key is their sort keys).
|
||||
levels: Vec<String>,
|
||||
pages: Vec<PageRef>,
|
||||
see: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every XE entry in the body with its page (or page range), grouped by entry.
|
||||
fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
||||
/// 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> {
|
||||
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 };
|
||||
@@ -197,7 +199,7 @@ fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
||||
})
|
||||
};
|
||||
let starts = s.doc.bookmarks();
|
||||
let mut tree: BTreeMap<Vec<String>, Node> = BTreeMap::new();
|
||||
let mut tree: BTreeMap<Vec<SortKey>, 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();
|
||||
@@ -207,10 +209,10 @@ fn collect(s: &mut Session) -> BTreeMap<Vec<String>, 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| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
tree.entry(head.iter().map(|x| sort_key(x, lang)).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
}
|
||||
let node =
|
||||
tree.entry(levels.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() });
|
||||
tree.entry(levels.iter().map(|x| sort_key(x, lang)).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());
|
||||
@@ -278,7 +280,9 @@ 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());
|
||||
let tree = collect(s);
|
||||
// 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 mut lines: Vec<(usize, Line)> = Vec::new();
|
||||
let mut letter: Option<String> = None;
|
||||
let mut keys = tree.keys().peekable();
|
||||
@@ -286,7 +290,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 = main.chars().next().map(|c| c.to_uppercase().to_string());
|
||||
let first = heading_letter(main, lang.as_deref());
|
||||
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,19 +183,11 @@ pub fn base64_encode(b: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) 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") {
|
||||
#[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()));
|
||||
}
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
s.ui_requests.push(json!({"open": "insertPicture"}));
|
||||
return sel_result(s);
|
||||
|
||||
@@ -320,14 +320,8 @@ 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> {
|
||||
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()))
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)
|
||||
}
|
||||
|
||||
/// Field names and records from a table's rows: with `headers`, the first row names the fields
|
||||
|
||||
@@ -39,6 +39,7 @@ 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;
|
||||
@@ -76,6 +77,7 @@ pub fn registry() -> Registry {
|
||||
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());
|
||||
@@ -130,6 +132,19 @@ 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();
|
||||
@@ -275,7 +290,18 @@ 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);
|
||||
@@ -283,6 +309,17 @@ 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) {
|
||||
@@ -398,6 +435,24 @@ 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,15 +203,7 @@ 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") {
|
||||
#[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()));
|
||||
}
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
|
||||
@@ -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", "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|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."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -95,10 +95,23 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let units = match v.get("units") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(u) => Some(
|
||||
// `auto` forgets the choice: lengths follow the region again (#838).
|
||||
Some(Value::String(a)) if a == "auto" => Some(None),
|
||||
Some(u) => Some(Some(
|
||||
serde_json::from_value::<wordcraft_geom::Unit>(u.clone())
|
||||
.map_err(|_| CmdError::Params("`units` is one of inches, centimeters, millimeters, points, picas".into()))?,
|
||||
),
|
||||
.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())),
|
||||
};
|
||||
let mut flag_values = Vec::new();
|
||||
for (k, f) in flags() {
|
||||
@@ -125,6 +138,7 @@ 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()
|
||||
@@ -139,6 +153,9 @@ 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;
|
||||
}
|
||||
@@ -154,6 +171,9 @@ 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();
|
||||
@@ -170,10 +190,14 @@ 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": pr.units,
|
||||
"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(),
|
||||
"typingReplacesSelection": pr.typing_replaces_selection,
|
||||
"insertKeyOvertype": pr.insert_key_overtype,
|
||||
"overtype": pr.overtype,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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};
|
||||
@@ -78,6 +79,11 @@ 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));
|
||||
@@ -85,17 +91,35 @@ 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}"#),
|
||||
.params(r#"{"before"?: pt, "after"?: pt, "beforeAuto"?: bool, "afterAuto"?: bool (automatic, HTML-style spacing: 14 pt)}"#),
|
||||
CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let has = cur(s).space_before > 0.0;
|
||||
fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 }))
|
||||
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);
|
||||
}
|
||||
})
|
||||
})
|
||||
.key("Mod+0"),
|
||||
CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let has = cur(s).space_after > 0.0;
|
||||
fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 }))
|
||||
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);
|
||||
}
|
||||
})
|
||||
}),
|
||||
// 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.
|
||||
@@ -230,8 +254,12 @@ fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
}
|
||||
|
||||
pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_paragraphs(&a, &b, f)?;
|
||||
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();
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -311,8 +339,8 @@ fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
@@ -333,8 +361,8 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
@@ -396,7 +424,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();
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
fmt(s, &|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) {
|
||||
@@ -433,10 +461,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, b) = s.sel.ordered();
|
||||
let a = s.sel.ordered().0;
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> =
|
||||
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();
|
||||
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();
|
||||
let same_kind = |n: &NumRef| {
|
||||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
@@ -802,28 +830,42 @@ 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 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));
|
||||
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);
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
/// 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))
|
||||
}
|
||||
|
||||
fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -1146,3 +1188,29 @@ 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 }
|
||||
}
|
||||
|
||||
+130
-10
@@ -1,9 +1,11 @@
|
||||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
|
||||
//! 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.
|
||||
//!
|
||||
//! 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` or `rtf` too. Text that is itself RTF or HTML source
|
||||
//! can also be pasted as that format.
|
||||
//! 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).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -17,18 +19,25 @@ 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); 4] =
|
||||
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
|
||||
pub const FORMATS: [(&str, &str); 5] = [
|
||||
("formatted", "Formatted Text"),
|
||||
("rtf", "Rich Text Format (RTF)"),
|
||||
("html", "HTML Format"),
|
||||
("picture", "Picture"),
|
||||
("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"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -63,6 +72,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
}
|
||||
"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,
|
||||
}
|
||||
@@ -83,11 +93,14 @@ 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"] {
|
||||
for k in ["text", "html", "rtf", "picture"] {
|
||||
if let Some(t) = p::str(v, k) {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
@@ -99,8 +112,9 @@ 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 or text)"))),
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))),
|
||||
};
|
||||
let Some(src) = source(s, v, fmt) else {
|
||||
return Err(CmdError::Failed(if ids.is_empty() {
|
||||
@@ -116,8 +130,13 @@ 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(&src)),
|
||||
"html" => adopt(s, wordcraft_formats::html::import(html_fragment(&src))),
|
||||
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
|
||||
};
|
||||
insert(s, frag)?;
|
||||
@@ -126,6 +145,102 @@ 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.plain_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)?;
|
||||
@@ -171,7 +286,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
}
|
||||
|
||||
/// Every paragraph in a block, tables included (bounded depth).
|
||||
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
@@ -218,6 +333,11 @@ 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()
|
||||
|
||||
@@ -65,7 +65,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if w.is_empty() {
|
||||
return Err(CmdError::Params("no word".into()));
|
||||
}
|
||||
wordcraft_proof::add_word(&w);
|
||||
// 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(&[]);
|
||||
s.relayout();
|
||||
Ok(json!({"added": w}))
|
||||
})
|
||||
@@ -78,7 +84,10 @@ 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(),
|
||||
};
|
||||
wordcraft_proof::add_word(&w);
|
||||
// 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());
|
||||
}
|
||||
s.relayout();
|
||||
Ok(json!({"ignored": w}))
|
||||
})
|
||||
@@ -442,9 +451,42 @@ 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| {
|
||||
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 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)
|
||||
});
|
||||
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());
|
||||
@@ -1166,6 +1208,29 @@ mod tests {
|
||||
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 {
|
||||
@@ -1200,7 +1265,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, Size: 14"], "{list:#}");
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Bold cs, Size: 14, Size cs: 14"], "{list:#}");
|
||||
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count();
|
||||
|
||||
+223
-35
@@ -1,8 +1,10 @@
|
||||
//! 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, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -175,6 +177,7 @@ 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))))
|
||||
@@ -384,6 +387,13 @@ 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 {
|
||||
@@ -454,7 +464,8 @@ fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
/// 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 {
|
||||
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);
|
||||
@@ -465,7 +476,8 @@ fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
fix_caret(s, &tp, if below { r + 1 } else { r }, c)
|
||||
}
|
||||
|
||||
fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
/// 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 {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
let g = t.grid_col(r, c);
|
||||
@@ -618,22 +630,47 @@ 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");
|
||||
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()),
|
||||
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),
|
||||
};
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
t.rows.extend(rows.into_iter().map(|(_, r)| r));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -664,14 +701,27 @@ 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 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()
|
||||
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()
|
||||
} else {
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).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") {
|
||||
@@ -685,7 +735,7 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
nums.iter().sum()
|
||||
};
|
||||
let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") };
|
||||
let text = format_number(val, out_lang.as_deref());
|
||||
let props = s.typing_props();
|
||||
let at = s.sel.focus.clone();
|
||||
let end = s.doc.insert_object(
|
||||
@@ -700,26 +750,164 @@ 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 (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),
|
||||
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 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 } };
|
||||
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;
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub struct DrawnRow {
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
/// Grid lines that are cell borders in this row (a merged cell hides the ones it covers).
|
||||
edges: Vec<usize>,
|
||||
pub(crate) edges: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A table border (or the handle) under the pointer, with the table's drawn geometry.
|
||||
@@ -145,15 +145,15 @@ impl ResizeTarget {
|
||||
}
|
||||
|
||||
/// A table cell's area on a page.
|
||||
struct CellBox {
|
||||
rect: Rect,
|
||||
table: Path,
|
||||
story: StoryRef,
|
||||
row: usize,
|
||||
cell: usize,
|
||||
pub(crate) struct CellBox {
|
||||
pub(crate) rect: Rect,
|
||||
pub(crate) table: Path,
|
||||
pub(crate) story: StoryRef,
|
||||
pub(crate) row: usize,
|
||||
pub(crate) cell: usize,
|
||||
}
|
||||
|
||||
fn cell_boxes(l: &DocLayout, page: usize) -> Vec<CellBox> {
|
||||
pub(crate) fn cell_boxes(l: &DocLayout, page: usize) -> Vec<CellBox> {
|
||||
let Some(pg) = l.pages.get(page) else { return Vec::new() };
|
||||
pg.items
|
||||
.iter()
|
||||
@@ -184,10 +184,12 @@ fn geometry(t: &Table, boxes: &[&CellBox]) -> Option<(Vec<f32>, Vec<DrawnRow>)>
|
||||
if let Some(x) = lines.get_mut(end) {
|
||||
x.get_or_insert(b.rect.right());
|
||||
}
|
||||
// A vertically merged cell's box spans all its rows (#640), so a row ends at its
|
||||
// shortest box.
|
||||
match rows.iter_mut().find(|r| r.row == b.row) {
|
||||
Some(r) => {
|
||||
r.top = r.top.min(b.rect.y);
|
||||
r.bottom = r.bottom.max(b.rect.bottom());
|
||||
r.bottom = r.bottom.min(b.rect.bottom());
|
||||
r.edges.extend([g, end]);
|
||||
}
|
||||
None => rows.push(DrawnRow { row: b.row, top: b.rect.y, bottom: b.rect.bottom(), edges: vec![g, end] }),
|
||||
@@ -215,11 +217,19 @@ fn geometry(t: &Table, boxes: &[&CellBox]) -> Option<(Vec<f32>, Vec<DrawnRow>)>
|
||||
cols.push(x);
|
||||
}
|
||||
rows.sort_by_key(|r| r.row);
|
||||
// A row whose every cell starts a vertical merge has only boxes that span further: it ends
|
||||
// where the next row starts.
|
||||
let tops: Vec<(usize, f32)> = rows.iter().map(|r| (r.row, r.top)).collect();
|
||||
for (r, (next, top)) in rows.iter_mut().zip(tops.into_iter().skip(1)) {
|
||||
if next == r.row.saturating_add(1) && top < r.bottom {
|
||||
r.bottom = top.max(r.top);
|
||||
}
|
||||
}
|
||||
Some((cols, rows))
|
||||
}
|
||||
|
||||
/// Every table drawn on a page, with its geometry there (`edge` is a placeholder).
|
||||
fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<ResizeTarget> {
|
||||
pub(crate) fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<ResizeTarget> {
|
||||
let boxes = cell_boxes(l, page);
|
||||
let mut tables: Vec<(StoryRef, &Path)> = Vec::new();
|
||||
for b in &boxes {
|
||||
@@ -319,14 +329,8 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
if let Some(widths) = plan.widths
|
||||
&& widths.len() == t.cols()
|
||||
{
|
||||
t.grid = widths;
|
||||
// The drag's widths are the table's now: no preferred table width scales them again.
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
if let Some(widths) = plan.widths {
|
||||
set_widths(t, widths);
|
||||
}
|
||||
for (r, h) in &plan.heights {
|
||||
if let Some(row) = t.rows.get_mut(*r) {
|
||||
@@ -343,7 +347,7 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// An "at least" height leaves out the cells' margins and border bands, which the layout adds
|
||||
// (and which depend on the table style): measure what the rows came to and take the extra
|
||||
// off once. A row its text holds taller keeps the dragged height.
|
||||
let drawn = drawn_heights(&s.layout(), page, &target);
|
||||
let drawn = drawn_heights(&s.layout(), &s.doc, page, &target);
|
||||
let mut fixed = Vec::new();
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
for (r, want) in &plan.heights {
|
||||
@@ -360,7 +364,7 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
touch(s, &target)?;
|
||||
let again = drawn_heights(&s.layout(), page, &target);
|
||||
let again = drawn_heights(&s.layout(), &s.doc, page, &target);
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
for (r, want, got) in fixed {
|
||||
let now = again.iter().find(|(i, _)| *i == r).map_or(got, |(_, h)| *h);
|
||||
@@ -374,20 +378,28 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The drawn height of each of the target table's rows on `page`.
|
||||
fn drawn_heights(l: &DocLayout, page: usize, target: &ResizeTarget) -> Vec<(usize, f32)> {
|
||||
let mut out: Vec<(usize, f32)> = Vec::new();
|
||||
for b in cell_boxes(l, page).iter().filter(|b| b.story == target.story && b.table == target.table) {
|
||||
match out.iter_mut().find(|(r, _)| *r == b.row) {
|
||||
Some((_, h)) => *h = h.max(b.rect.h),
|
||||
None => out.push((b.row, b.rect.h)),
|
||||
}
|
||||
/// Make `widths` (one per grid column) the table's column widths, as drawn: no preferred table
|
||||
/// width scales them again.
|
||||
pub(crate) fn set_widths(t: &mut Table, widths: Vec<f32>) {
|
||||
if widths.len() == t.cols() {
|
||||
t.grid = widths;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawn height of each of the target table's rows on `page`.
|
||||
fn drawn_heights(l: &DocLayout, doc: &Document, page: usize, target: &ResizeTarget) -> Vec<(usize, f32)> {
|
||||
tables_on(l, doc, page)
|
||||
.into_iter()
|
||||
.find(|t| t.story == target.story && t.table == target.table)
|
||||
.map(|t| t.rows.iter().map(|r| (r.row, r.bottom - r.top)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mark the table's paragraphs changed, so the layout redoes them.
|
||||
fn touch(s: &mut Session, target: &ResizeTarget) -> Result<(), CmdError> {
|
||||
pub(crate) fn touch(s: &mut Session, target: &ResizeTarget) -> Result<(), CmdError> {
|
||||
let story = target.story;
|
||||
let paths: Vec<Path> =
|
||||
s.doc.para_paths(story).into_iter().filter(|q| q.0.len() > target.table.0.len() && q.0.starts_with(&target.table.0)).collect();
|
||||
@@ -522,6 +534,28 @@ mod tests {
|
||||
assert_eq!(t.edge, Edge::Column(1));
|
||||
}
|
||||
|
||||
/// A vertical merge lays out as one box as tall as its rows (#640); each row keeps its own
|
||||
/// bottom border, and dragging one changes that row alone.
|
||||
#[test]
|
||||
fn a_vertical_merge_keeps_each_rows_border() {
|
||||
let mut s = table_session(3, 2);
|
||||
let (m, a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 1, 1));
|
||||
s.run("table.eraser", &json!({"page": 0, "x": m.x + m.w / 2.0, "y": m.bottom() + 0.5})).unwrap();
|
||||
assert_eq!(first_table(&s).rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Continue);
|
||||
let l = s.layout();
|
||||
let x = b.x + b.w / 2.0;
|
||||
let t = resize_target(&l, &s.doc, 0, x, a.bottom() + 1.0, REACH).unwrap();
|
||||
assert_eq!(t.edge, Edge::Row(0), "the border between the merged rows is still there");
|
||||
let t = resize_target(&l, &s.doc, 0, x, b.bottom() + 1.0, REACH).unwrap();
|
||||
assert_eq!(t.edge, Edge::Row(1), "the merge's bottom is the second row's border");
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": a.bottom() + 1.0, "toX": x, "toY": a.bottom() + 21.0})).unwrap();
|
||||
let t = first_table(&s);
|
||||
let h = cell_rect(&mut s, 0, 1).h;
|
||||
assert!((h - (a.h + 20.0)).abs() < 0.1, "{} → {h}", a.h);
|
||||
assert_eq!(t.rows[1].props.height, None, "the other rows are untouched");
|
||||
assert!((cell_rect(&mut s, 1, 1).h - b.h).abs() < 0.1);
|
||||
}
|
||||
|
||||
/// Away from any border, or with hostile numbers: an error and no change, never a panic.
|
||||
#[test]
|
||||
fn misses_and_hostile_params_change_nothing() {
|
||||
|
||||
@@ -509,8 +509,18 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
// At the start of a paragraph.
|
||||
if para.props.numbering.is_some_and(|n| n.num != 0) {
|
||||
// First Backspace removes the number but keeps the item's indent (Word).
|
||||
// First Backspace removes the number but keeps the item's indent (Word): the text stays
|
||||
// where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it).
|
||||
let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
if p.props.style.as_deref() == Some("ListParagraph") {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_left = text_at;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_first = Some(0.0);
|
||||
}
|
||||
}
|
||||
p.props.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
|
||||
@@ -60,6 +60,11 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
("3/4", "¾"),
|
||||
];
|
||||
|
||||
/// A built-in entry that is an English word (letters and apostrophes only), not a symbol.
|
||||
fn is_english_entry(from: &str) -> bool {
|
||||
from.chars().any(char::is_alphabetic) && from.chars().all(|c| c.is_alphabetic() || c == '\'')
|
||||
}
|
||||
|
||||
/// Compare and Combine take the same settings.
|
||||
const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
|
||||
|
||||
@@ -71,12 +76,16 @@ pub struct AutoCorrectPrefs {
|
||||
pub enabled: bool,
|
||||
/// Replace text as you type (the replacement list).
|
||||
pub replace_text: bool,
|
||||
/// Correct TWo INitial CApitals: "WOrd" → "Word" (not the exceptions, e.g. "IDs").
|
||||
pub two_initial_caps: bool,
|
||||
/// Capitalize the first letter of sentences.
|
||||
pub cap_sentences: bool,
|
||||
/// Capitalize the first letter of table cells.
|
||||
pub cap_cells: bool,
|
||||
/// Capitalize names of days.
|
||||
pub cap_days: bool,
|
||||
/// Correct accidental usage of cAPS LOCK: "wORD" → "Word".
|
||||
pub caps_lock: bool,
|
||||
/// AutoFormat As You Type: "straight quotes" with “smart quotes”.
|
||||
pub smart_quotes: bool,
|
||||
/// Fractions (1/2) with fraction characters (½).
|
||||
@@ -101,6 +110,8 @@ pub struct AutoCorrectPrefs {
|
||||
pub exceptions: Vec<String>,
|
||||
/// Built-in abbreviations the user deleted from the exceptions.
|
||||
pub exceptions_removed: Vec<String>,
|
||||
/// The user's words that keep two initial capitals (besides the built-in ones).
|
||||
pub initial_caps_exceptions: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AutoCorrectPrefs {
|
||||
@@ -108,9 +119,11 @@ impl Default for AutoCorrectPrefs {
|
||||
AutoCorrectPrefs {
|
||||
enabled: true,
|
||||
replace_text: true,
|
||||
two_initial_caps: true,
|
||||
cap_sentences: true,
|
||||
cap_cells: true,
|
||||
cap_days: true,
|
||||
caps_lock: true,
|
||||
smart_quotes: true,
|
||||
fractions: true,
|
||||
ordinals: true,
|
||||
@@ -123,6 +136,7 @@ impl Default for AutoCorrectPrefs {
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
exceptions_removed: Vec::new(),
|
||||
initial_caps_exceptions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +159,7 @@ impl AutoCorrectPrefs {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.entries.retain(|(a, b)| ok(a, MAX_FROM) && b.chars().count() <= MAX_TO && !b.contains(['\n', '\r']) && seen.insert(a.clone()));
|
||||
self.entries.truncate(MAX_ENTRIES);
|
||||
for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed] {
|
||||
for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed, &mut self.initial_caps_exceptions] {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
list.retain(|a| ok(a, MAX_FROM) && seen.insert(a.clone()));
|
||||
list.truncate(MAX_ENTRIES);
|
||||
@@ -153,14 +167,23 @@ impl AutoCorrectPrefs {
|
||||
self
|
||||
}
|
||||
|
||||
/// The replacement for `word`, if any: the user's entry, else a built-in one not deleted.
|
||||
/// Built-in lowercase entries also match a capitalised word ("Teh" → "The").
|
||||
/// The replacement for `word` in English text, if any: the user's entry, else a built-in
|
||||
/// one not deleted. Built-in lowercase entries also match a capitalised word ("Teh" → "The").
|
||||
pub fn replacement(&self, word: &str) -> Option<String> {
|
||||
self.replacement_in(word, true)
|
||||
}
|
||||
|
||||
/// The replacement for `word` in text that is (`english`) or isn't English: the built-in
|
||||
/// English words ("teh", "i", "dont") apply only to English text, as Word keeps a list per
|
||||
/// language (#835: Polish "i" means "and"); symbols, fractions and the user's entries apply
|
||||
/// whatever the language.
|
||||
pub fn replacement_in(&self, word: &str, english: bool) -> Option<String> {
|
||||
if let Some((_, b)) = self.entries.iter().find(|(a, _)| a == word) {
|
||||
return Some(b.clone());
|
||||
}
|
||||
AUTOCORRECT
|
||||
.iter()
|
||||
.filter(|(a, _)| english || !is_english_entry(a))
|
||||
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
|
||||
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
|
||||
.find(|(a, _)| {
|
||||
@@ -216,9 +239,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for (k, f) in [
|
||||
("enabled", &mut ac.enabled as &mut bool),
|
||||
("replaceText", &mut ac.replace_text),
|
||||
("twoInitialCaps", &mut ac.two_initial_caps),
|
||||
("capSentences", &mut ac.cap_sentences),
|
||||
("capCells", &mut ac.cap_cells),
|
||||
("capDays", &mut ac.cap_days),
|
||||
("capsLock", &mut ac.caps_lock),
|
||||
("smartQuotes", &mut ac.smart_quotes),
|
||||
("fractions", &mut ac.fractions),
|
||||
("ordinals", &mut ac.ordinals),
|
||||
@@ -273,13 +298,31 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
ac.exceptions_removed.push(e);
|
||||
}
|
||||
}
|
||||
if let Some(w) = p::str(v, "addInitialCapsException") {
|
||||
let w = w.trim();
|
||||
if bad(w, MAX_FROM) || w.contains(char::is_whitespace) {
|
||||
return Err(CmdError::Params("an INitial CAps exception is one word, like \"IDs\"".into()));
|
||||
}
|
||||
if !INITIAL_CAPS_EXCEPTIONS.contains(&w)
|
||||
&& !ac.initial_caps_exceptions.iter().any(|e| e == w)
|
||||
&& ac.initial_caps_exceptions.len() < MAX_ENTRIES
|
||||
{
|
||||
ac.initial_caps_exceptions.push(w.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(w) = p::str(v, "deleteInitialCapsException") {
|
||||
let w = w.trim();
|
||||
ac.initial_caps_exceptions.retain(|e| e != w);
|
||||
}
|
||||
let ac = &s.prefs.autocorrect;
|
||||
Ok(json!({
|
||||
"enabled": ac.enabled,
|
||||
"replaceText": ac.replace_text,
|
||||
"twoInitialCaps": ac.two_initial_caps,
|
||||
"capSentences": ac.cap_sentences,
|
||||
"capCells": ac.cap_cells,
|
||||
"capDays": ac.cap_days,
|
||||
"capsLock": ac.caps_lock,
|
||||
"smartQuotes": ac.smart_quotes,
|
||||
"fractions": ac.fractions,
|
||||
"ordinals": ac.ordinals,
|
||||
@@ -291,6 +334,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
"user": ac.entries,
|
||||
"exceptions": ac.exception_list(),
|
||||
"initialCapsExceptions": INITIAL_CAPS_EXCEPTIONS.iter().map(|e| e.to_string()).chain(ac.initial_caps_exceptions.iter().cloned()).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -321,7 +365,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"capSentences"|"capCells"|"capDays"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
@@ -621,6 +665,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("view.sideToSide", "Side to Side", "View › Page Movement", |s, _| {
|
||||
s.view.multi_page = true;
|
||||
s.view.single_column = false;
|
||||
s.view.fit = "multiplePages".into();
|
||||
Ok(json!({"pageMovement": "sideToSide"}))
|
||||
})
|
||||
@@ -714,7 +759,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
return Ok(());
|
||||
}
|
||||
let ac = s.prefs.autocorrect.clone();
|
||||
let f = s.sel.focus.clone();
|
||||
let mut f = s.sel.focus.clone();
|
||||
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
|
||||
let Some(before) = text.get(..f.off) else { return Ok(()) };
|
||||
let Some(last) = before.chars().next_back() else { return Ok(()) };
|
||||
@@ -724,12 +769,39 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
let trigger = if on_enter { 0 } else { last.len_utf8() };
|
||||
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
|
||||
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
|
||||
let Some(word) = body.get(start..).map(str::to_string) else { return Ok(()) };
|
||||
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// TWo INitial CApitals and cAPS LOCK come first, so "TEh" is still a replacement's typo.
|
||||
if let Some((at, reps)) = case_fix(&ac, &word) {
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let letters = word.get(at..).unwrap_or("").chars().take(reps.len()).collect::<String>();
|
||||
let mut end = start + at + letters.len();
|
||||
for (c, rep) in letters.chars().rev().zip(reps.iter().rev()) {
|
||||
let from = end.saturating_sub(c.len_utf8());
|
||||
if rep.chars().ne(std::iter::once(c)) {
|
||||
let props = para.props_of_char(from).clone();
|
||||
para.delete(from, end)?;
|
||||
para.insert_text(from, rep, &props)?;
|
||||
}
|
||||
end = from;
|
||||
}
|
||||
let fixed: String = reps.concat();
|
||||
f.off = (f.off + fixed.len()).saturating_sub(letters.len());
|
||||
s.sel = Selection::caret(f.clone());
|
||||
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
|
||||
}
|
||||
// A whole-word replacement, else "wait..." → "wait…".
|
||||
let found = if ac.replace_text { ac.replacement(&word) } else { None };
|
||||
// The word's language (run, paragraph style, document default) picks the list (#835).
|
||||
// Text with no language at all is in the editing language (a Polish interface writes Polish),
|
||||
// else English. Proofing decides what is English the same way.
|
||||
let english = s.doc.para_at(&f).is_none_or(|p| {
|
||||
let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start));
|
||||
let lang = rc.lang.as_deref().or(s.editing_lang.as_deref());
|
||||
wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::En
|
||||
});
|
||||
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
|
||||
@@ -804,6 +876,49 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations).
|
||||
pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"];
|
||||
|
||||
/// Words with a lowercase first letter and the rest in capitals that aren't cAPS LOCK slips.
|
||||
const CAPS_LOCK_EXCEPTIONS: &[&str] = &["iOS", "mRNA", "tRNA", "rRNA", "cDNA", "eSIM", "eSATA", "iSCSI", "vCPU"];
|
||||
|
||||
/// The capitalisation fix for a finished word: TWo INitial CApitals ("WOrd" → "Word") or cAPS
|
||||
/// LOCK ("wORD" → "Word"). Returns the byte offset of the word's letters and each letter's
|
||||
/// replacement. Only words of three or more letters, optionally in quotes or brackets.
|
||||
fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec<String>)> {
|
||||
if !caps.two_initial_caps && !caps.caps_lock {
|
||||
return None;
|
||||
}
|
||||
let at = word.find(char::is_alphabetic)?;
|
||||
let rest = word.get(at..)?;
|
||||
let letters = rest.get(..rest.find(|c: char| !c.is_alphabetic()).unwrap_or(rest.len()))?;
|
||||
let tail = rest.get(letters.len()..)?;
|
||||
if !word.get(..at)?.chars().all(|c| "\"'“‘([".contains(c)) || !tail.chars().all(|c| "\"'”’)]".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
let cs: Vec<char> = letters.chars().collect();
|
||||
let (&a, &b, more) = match cs.as_slice() {
|
||||
[a, b, more @ ..] if !more.is_empty() => (a, b, more),
|
||||
_ => return None,
|
||||
};
|
||||
let one = |c: char| c.to_string();
|
||||
let lower = |c: char| c.to_lowercase().collect::<String>();
|
||||
if caps.two_initial_caps
|
||||
&& a.is_uppercase()
|
||||
&& b.is_uppercase()
|
||||
&& more.iter().all(|c| c.is_lowercase())
|
||||
&& !INITIAL_CAPS_EXCEPTIONS.contains(&letters)
|
||||
&& !caps.initial_caps_exceptions.iter().any(|e| e == letters)
|
||||
{
|
||||
return Some((at, std::iter::once(one(a)).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| one(c))).collect()));
|
||||
}
|
||||
if caps.caps_lock && a.is_lowercase() && b.is_uppercase() && more.iter().all(|c| c.is_uppercase()) && !CAPS_LOCK_EXCEPTIONS.contains(&letters) {
|
||||
let up = a.to_uppercase().collect::<String>();
|
||||
return Some((at, std::iter::once(up).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| lower(c))).collect()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
|
||||
fn ordinal_suffix(word: &str) -> Option<usize> {
|
||||
let digits = word.find(|c: char| !c.is_ascii_digit())?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! View tab and status bar: views, zoom, show/hide, panes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_layout::ViewMode;
|
||||
use wordcraft_layout::{NumeralMode, ViewMode};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -27,6 +27,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// The body as an outline of its headings, with the Outlining tab (`outline.*`).
|
||||
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(),
|
||||
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
|
||||
CommandSpec::new("view.readColumnWidth", "Reading Column Width", "Reading › Layout", read_column_width)
|
||||
.params(r#"{"value": "auto" | "narrow" | "medium" | "wide" | "page"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.readTextSize", "Reading Text Size", "Reading › Layout", read_text_size)
|
||||
.params(r#"{"value": percent (75–150)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
|
||||
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
|
||||
.pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -83,21 +92,79 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
|
||||
.pure(),
|
||||
// Word › File › Options › Advanced › Numeral: how digits look on screen (display
|
||||
// only; the document text keeps the typed codepoints).
|
||||
CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") {
|
||||
None => Ok(json!({"value": s.view.numeral})),
|
||||
Some(Value::String(name)) => {
|
||||
let Some(mode) = NumeralMode::parse(name) else {
|
||||
return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into()));
|
||||
};
|
||||
s.view.numeral = mode;
|
||||
s.relayout();
|
||||
Ok(json!({"value": s.view.numeral}))
|
||||
}
|
||||
Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())),
|
||||
})
|
||||
.params(r#"{"value"?: "arabic|hindi|context|system"}"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
if read && !s.view.read_mode {
|
||||
// Reading controls are session-only, and reset on each entry to the view.
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
s.relayout();
|
||||
Ok(json!({"mode": m, "readMode": read}))
|
||||
}
|
||||
|
||||
fn read_column_width(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let width = match v.get("value").and_then(Value::as_str) {
|
||||
Some("auto") => None,
|
||||
Some("narrow") => Some(180.0),
|
||||
Some("medium") => Some(240.0),
|
||||
Some("wide") => Some(350.0),
|
||||
Some("page") => Some(612.0),
|
||||
_ => return Err(CmdError::Params("column width must be auto, narrow, medium, wide, or page".into())),
|
||||
};
|
||||
s.view.read_column_width = width;
|
||||
Ok(json!({"width": width}))
|
||||
}
|
||||
|
||||
fn read_text_size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(pct) = v.get("value").and_then(Value::as_f64) else {
|
||||
return Err(CmdError::Params("reading text size must be a percentage".into()));
|
||||
};
|
||||
if !pct.is_finite() || !(75.0..=150.0).contains(&pct) {
|
||||
return Err(CmdError::Params("reading text size must be 75–150%".into()));
|
||||
}
|
||||
s.view.read_zoom = pct as f32 / 100.0;
|
||||
Ok(json!({"percent": pct}))
|
||||
}
|
||||
|
||||
fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let value = v.get("value");
|
||||
let spacing = match value {
|
||||
Some(Value::String(s)) if s == "document" => None,
|
||||
Some(Value::Number(n)) if matches!(n.as_f64(), Some(1.0 | 1.25 | 1.5 | 2.0)) => n.as_f64().map(|n| n as f32),
|
||||
_ => return Err(CmdError::Params("reading line spacing must be document, 1, 1.25, 1.5, or 2".into())),
|
||||
};
|
||||
s.view.read_line_spacing = spacing;
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||||
match v.get("value") {
|
||||
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
|
||||
s.view.fit = f.clone();
|
||||
s.view.multi_page = f == "multiplePages";
|
||||
s.view.single_column = f == "onePage" || f == "pageWidth";
|
||||
}
|
||||
Some(x) => {
|
||||
let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32;
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! The folders automation may read and write (#769).
|
||||
//!
|
||||
//! `wordcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
|
||||
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots
|
||||
//! nothing is checked.
|
||||
//!
|
||||
//! A path is checked, then opened: a process that can change the folders in between (swapping in
|
||||
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
|
||||
//! guard against other programs on the computer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Scope {
|
||||
Confined(Arc<AutomationRoots>),
|
||||
Unconfined,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
||||
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
|
||||
|
||||
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
|
||||
/// (they stay as they were).
|
||||
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
|
||||
PROCESS.set(roots).is_ok()
|
||||
}
|
||||
|
||||
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
|
||||
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
|
||||
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
|
||||
match roots {
|
||||
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
|
||||
/// app's own folders.
|
||||
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
|
||||
with_scope(Some(Scope::Unconfined), f)
|
||||
}
|
||||
|
||||
/// The roots in force on this thread, if any.
|
||||
pub fn current() -> Option<Arc<AutomationRoots>> {
|
||||
match SCOPE.with(|s| s.borrow().clone()) {
|
||||
Some(Scope::Confined(r)) => Some(r),
|
||||
Some(Scope::Unconfined) => None,
|
||||
None => PROCESS.get().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_string_lossy();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) => PathBuf::from(rest),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// May `path` be read here? Always, without roots in force.
|
||||
pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.docx"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.docx"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.docx"), "x").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/A.SVG"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_stay_on_their_thread() {
|
||||
let (base, roots) = folders("scopes");
|
||||
let outside = s(base.join("outside/secret.docx"));
|
||||
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
|
||||
confine(Some(&roots), || {
|
||||
assert!(check_read(&outside).is_err());
|
||||
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
|
||||
assert!(check_read(&outside).is_err(), "restored after unconfined");
|
||||
let o = outside.clone();
|
||||
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
|
||||
assert!(there, "another thread is not confined by this thread's scope");
|
||||
});
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
|
||||
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
|
||||
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_names() {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] {
|
||||
assert!(is_device_name(d), "{d}");
|
||||
}
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file a command reads or writes by path goes through `io` (`open_path_with`,
|
||||
/// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that
|
||||
/// reaches for `std::fs` itself would bypass them, so none may: every file under `src/`
|
||||
/// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`,
|
||||
/// reached only after `io` checked the roots), this module, and the test files.
|
||||
#[test]
|
||||
fn commands_do_not_touch_the_file_system_themselves() {
|
||||
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
walk(&path, files);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = vec![];
|
||||
walk(&src, &mut files);
|
||||
assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
|
||||
let mut offenders = vec![];
|
||||
for path in files {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
|
||||
if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs" || name.starts_with("tests"))
|
||||
|| path.starts_with(src.join("io"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// Test modules (which build their own fixtures) come last in each file.
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let line = line.trim_start();
|
||||
let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w));
|
||||
if !line.starts_with("//") && touches {
|
||||
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}");
|
||||
}
|
||||
}
|
||||
+82
-1
@@ -126,6 +126,43 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
||||
Ok((doc, false))
|
||||
}
|
||||
|
||||
/// The narrowest text width of the document's sections, points: what a picture must fit.
|
||||
pub(crate) fn text_width(doc: &Document) -> f32 {
|
||||
doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0)
|
||||
}
|
||||
|
||||
/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a
|
||||
/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML,
|
||||
/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does.
|
||||
pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) {
|
||||
crate::cmd::paste::each_para(b, 0, &mut |p| {
|
||||
let mut changed = false;
|
||||
for o in &mut p.objects {
|
||||
if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o
|
||||
&& float.wrap == wordcraft_doc::para::Wrap::Inline
|
||||
&& w.is_finite()
|
||||
&& *w > max_w
|
||||
{
|
||||
let k = max_w / *w;
|
||||
*w = max_w;
|
||||
*h = (*h * k).max(1.0);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
p.touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// [`fit_block_pictures`] for a whole document read from a file, against its own text width.
|
||||
pub(crate) fn fit_pictures(doc: &mut Document) {
|
||||
let max_w = text_width(doc);
|
||||
for b in &mut doc.body {
|
||||
fit_block_pictures(std::sync::Arc::make_mut(b), max_w);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
|
||||
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
|
||||
/// other text through the plain-text import with encoding detection (#569). Anything else is
|
||||
@@ -191,6 +228,7 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.len() > 2 << 30 {
|
||||
return Err(format!("{}: file is larger than 2 GB", path.display()));
|
||||
@@ -201,6 +239,7 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
|
||||
// Pictures referenced by a relative path load from beside the HTML file.
|
||||
let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new("")));
|
||||
let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src));
|
||||
fit_pictures(&mut doc);
|
||||
doc.ensure_nonempty();
|
||||
return Ok((doc, false));
|
||||
}
|
||||
@@ -209,7 +248,8 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
|
||||
|
||||
/// The pictures an HTML file names by a path relative to its folder.
|
||||
///
|
||||
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. URLs
|
||||
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is
|
||||
/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs
|
||||
/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and
|
||||
/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for
|
||||
/// the whole document, so one large picture can't be multiplied by referencing it many times.
|
||||
@@ -310,6 +350,46 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data).
|
||||
/// Every such read goes through here so the automation read root applies ([`crate::file_access`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("{path}: files are opened through the browser on the web"))
|
||||
}
|
||||
|
||||
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
|
||||
/// anything is read (a font named by the user, which could be any size).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
crate::file_access::check_write(path)?;
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
|
||||
Err(format!("{path}: files are saved through the browser on the web"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
Err(format!("{}: files are opened through the browser on the web", path.display()))
|
||||
@@ -322,6 +402,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
|
||||
crate::file_access::check_write(&path.to_string_lossy())?;
|
||||
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
|
||||
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
other => wordcraft_formats::import(other, bytes),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
other => wordcraft_formats::import(other, bytes).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
doc
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod file_access;
|
||||
pub mod io;
|
||||
mod io_ext;
|
||||
pub mod keymap;
|
||||
@@ -25,8 +26,11 @@ use serde_json::Value;
|
||||
pub use io::Password;
|
||||
pub use keymap::KeyMap;
|
||||
pub use ribbon::RibbonLayout;
|
||||
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use session::{
|
||||
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
|
||||
};
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_geom::Region;
|
||||
pub use wordcraft_layout as layout;
|
||||
pub use wordcraft_proof as proof;
|
||||
pub use wordcraft_render as render;
|
||||
|
||||
@@ -161,8 +161,9 @@ pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result<Value, Cmd
|
||||
let path = p::req_str(v, "path")?;
|
||||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||||
let (bytes, plan) = s.print_pdf(v)?;
|
||||
// Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -215,4 +216,31 @@ mod tests {
|
||||
let r = s.run("file.printPdf", &json!({})).is_err();
|
||||
assert!(r, "printPdf needs a path");
|
||||
}
|
||||
|
||||
/// `file.printPdf` writes only inside the automation write root when there is one (#769).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_pdf_to_file_is_held_to_the_write_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap();
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let inside = base.join("out/job").to_string_lossy().into_owned();
|
||||
let outside = base.join("outside/job").to_string_lossy().into_owned();
|
||||
confine(roots.as_ref(), || {
|
||||
let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
s.run("file.printPdf", &json!({"path": inside})).unwrap();
|
||||
});
|
||||
assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root");
|
||||
assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF"));
|
||||
// Without roots nothing is checked: the behaviour is unchanged.
|
||||
s.run("file.printPdf", &json!({"path": outside})).unwrap();
|
||||
assert!(base.join("outside/job.pdf").is_file());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
+259
-21
@@ -1,13 +1,22 @@
|
||||
//! The editing session: document, selection, undo/redo, layout, view state.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System.
|
||||
static SYSTEM_LOCALE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Tell the engine the system locale (the shell calls this once at startup; layout never reads
|
||||
/// the environment). Headless sessions without it show Western digits for Numeral › System.
|
||||
pub fn set_system_locale(tag: &str) {
|
||||
let _ = SYSTEM_LOCALE.set(tag.to_string());
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode};
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef, Table};
|
||||
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
|
||||
|
||||
use crate::{CmdError, Registry};
|
||||
|
||||
@@ -42,6 +51,52 @@ pub struct ColumnBlock {
|
||||
pub focus_x: f32,
|
||||
/// One `(start, end)` per line, in document order, each inside one paragraph.
|
||||
pub segments: Vec<(Pos, Pos)>,
|
||||
/// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in
|
||||
/// a table's selection areas): one segment per paragraph of the cells.
|
||||
pub cells: Option<CellRange>,
|
||||
}
|
||||
|
||||
/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into
|
||||
/// grid columns `cols.0..=cols.1`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CellRange {
|
||||
pub story: StoryRef,
|
||||
pub table: Path,
|
||||
pub rows: (usize, usize),
|
||||
pub cols: (usize, usize),
|
||||
/// The cells are one run of the text (cells of one row, or whole rows), so the stream
|
||||
/// selection between the corners holds just them and commands act on that as usual.
|
||||
/// Otherwise (part of a column) text edits and formatting go cell by cell.
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
impl CellRange {
|
||||
/// The selected cells of table `t` (row, cell index), in document order.
|
||||
pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> {
|
||||
let ((r0, r1), (g0, g1)) = (self.rows, self.cols);
|
||||
let mut out = Vec::new();
|
||||
for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) {
|
||||
let mut g = 0usize;
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g <= g1 && end > g0 {
|
||||
out.push((r, c));
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`),
|
||||
/// and where it really runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Widened {
|
||||
/// The selection it became; this is current while `Session::sel` still equals it.
|
||||
pub sel: Selection,
|
||||
/// Where the selection was really extended from and to: extending it again goes on from here.
|
||||
pub ends: Selection,
|
||||
}
|
||||
|
||||
/// View state that commands can change (ribbon View tab, status bar).
|
||||
@@ -78,14 +133,33 @@ pub struct ViewState {
|
||||
#[serde(default)]
|
||||
pub alt_text_pane: bool,
|
||||
pub multi_page: bool,
|
||||
/// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode.
|
||||
#[serde(default)]
|
||||
pub single_column: bool,
|
||||
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
|
||||
pub fit: String,
|
||||
pub web_width: f32,
|
||||
/// Transient viewport metrics for reading pagination; never saved in a document or preferences.
|
||||
#[serde(skip, default = "default_read_width")]
|
||||
pub read_width: f32,
|
||||
#[serde(skip, default = "default_read_height")]
|
||||
pub read_height: f32,
|
||||
/// Reader-only layout controls; they are neither document formatting nor saved UI prefs.
|
||||
/// `None` chooses responsive column width and keeps the document's line spacing.
|
||||
#[serde(skip)]
|
||||
pub read_column_width: Option<f32>,
|
||||
#[serde(skip, default = "default_read_zoom")]
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
pub dark_mode: bool,
|
||||
pub show_markup: bool,
|
||||
pub track_changes_pane: bool,
|
||||
/// Check spelling and grammar as you type.
|
||||
pub proofing: bool,
|
||||
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
|
||||
#[serde(default)]
|
||||
pub numeral: NumeralMode,
|
||||
/// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document).
|
||||
#[serde(default)]
|
||||
pub hide_ink: bool,
|
||||
@@ -112,6 +186,18 @@ fn on() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_read_width() -> f32 {
|
||||
300.0
|
||||
}
|
||||
|
||||
fn default_read_height() -> f32 {
|
||||
500.0
|
||||
}
|
||||
|
||||
fn default_read_zoom() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
ViewState {
|
||||
@@ -132,12 +218,19 @@ impl Default for ViewState {
|
||||
accessibility_pane: false,
|
||||
alt_text_pane: false,
|
||||
multi_page: false,
|
||||
single_column: false,
|
||||
fit: String::new(),
|
||||
web_width: 800.0,
|
||||
read_width: default_read_width(),
|
||||
read_height: default_read_height(),
|
||||
read_column_width: None,
|
||||
read_zoom: default_read_zoom(),
|
||||
read_line_spacing: None,
|
||||
dark_mode: false,
|
||||
show_markup: true,
|
||||
track_changes_pane: false,
|
||||
proofing: true,
|
||||
numeral: NumeralMode::default(),
|
||||
hide_ink: false,
|
||||
draw: Default::default(),
|
||||
field_codes: false,
|
||||
@@ -183,6 +276,9 @@ struct Undo {
|
||||
sel: Selection,
|
||||
/// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too.
|
||||
pending: Option<CharProps>,
|
||||
/// The column block or whole cells the selection was (undoing brings the selection back as
|
||||
/// it was).
|
||||
column: Option<ColumnBlock>,
|
||||
}
|
||||
|
||||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||||
@@ -232,6 +328,11 @@ pub struct Session {
|
||||
pub pending: Option<CharProps>,
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
pub dirty: bool,
|
||||
/// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop
|
||||
/// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave
|
||||
/// `None`, so an agent's words stay in memory and never change the person's dictionary.
|
||||
/// Add to Dictionary and File › Options › Proofing save through it (#40).
|
||||
pub user_dict_path: Option<std::path::PathBuf>,
|
||||
pub clipboard: Option<Fragment>,
|
||||
/// A tracked cut the next paste can turn into a move (see `cmd::moves`).
|
||||
pub pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
@@ -268,9 +369,21 @@ pub struct Session {
|
||||
/// mail merge, recover), never on an edit.
|
||||
document_id: u64,
|
||||
cache: LayoutCache,
|
||||
/// (revision, web width, view, layout, proofing, outline, (deletions left out, body
|
||||
/// deletions in balloons)).
|
||||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool, Option<wordcraft_layout::outline::OutlineView>, (bool, bool))>,
|
||||
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
|
||||
/// (deletions left out, body deletions in balloons), numeral display).
|
||||
#[allow(clippy::type_complexity)]
|
||||
layout: Option<(
|
||||
u64,
|
||||
f32,
|
||||
f32,
|
||||
ViewMode,
|
||||
Option<u32>,
|
||||
Arc<DocLayout>,
|
||||
bool,
|
||||
Option<wordcraft_layout::outline::OutlineView>,
|
||||
(bool, bool),
|
||||
NumeralMode,
|
||||
)>,
|
||||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||||
pub originals: std::collections::HashMap<String, String>,
|
||||
/// Last mutating command (Repeat).
|
||||
@@ -293,6 +406,11 @@ pub struct Session {
|
||||
pub read_aloud: crate::speech::ReadAloud,
|
||||
/// Preferences the front end saves between runs.
|
||||
pub prefs: Prefs,
|
||||
/// The system region (#838): the paper a new blank document starts on, the default
|
||||
/// measurement unit and the decimal separator. The front end sets it from the platform (the
|
||||
/// browser's locale on the web); headless sessions keep `en-US`. A region the user chose
|
||||
/// ([`Prefs::region`]) wins: read it with [`Session::region`].
|
||||
pub system_region: wordcraft_geom::Region,
|
||||
/// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too.
|
||||
pub keymap: crate::KeyMap,
|
||||
/// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too.
|
||||
@@ -318,6 +436,9 @@ pub struct Session {
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
pub column_mode: bool,
|
||||
/// A selection widened to whole table cells or rows, and where it really runs (see
|
||||
/// [`Session::sel_ends`]).
|
||||
pub(crate) widened: Option<Widened>,
|
||||
/// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back
|
||||
/// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used.
|
||||
edit_marks: Vec<Pos>,
|
||||
@@ -359,8 +480,14 @@ pub struct Prefs {
|
||||
pub insert_key_overtype: bool,
|
||||
/// Overtype: typed characters replace the ones after the caret.
|
||||
pub overtype: bool,
|
||||
/// The unit rulers and dialogs show lengths in.
|
||||
pub units: wordcraft_geom::Unit,
|
||||
/// The unit rulers and dialogs show lengths in, when the user chose one (File › Options ›
|
||||
/// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under
|
||||
/// a new key; an older `units` value other than inches (the old default) carries over on load.
|
||||
#[serde(rename = "measurementUnits")]
|
||||
pub units: Option<wordcraft_geom::Unit>,
|
||||
/// The region the user chose for new documents, units and the decimal separator (a locale
|
||||
/// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system.
|
||||
pub region: Option<String>,
|
||||
/// Formatting marks shown even while Show/Hide ¶ is off.
|
||||
pub marks: wordcraft_layout::display::FormattingMarks,
|
||||
/// Mark grammar errors as you type (with Check spelling as you type on).
|
||||
@@ -384,6 +511,7 @@ impl Default for Prefs {
|
||||
insert_key_overtype: false,
|
||||
overtype: false,
|
||||
units: Default::default(),
|
||||
region: None,
|
||||
marks: Default::default(),
|
||||
mark_grammar: true,
|
||||
ignore_uppercase: true,
|
||||
@@ -414,8 +542,24 @@ fn doc_bib_style(doc: &Document) -> String {
|
||||
|
||||
/// Maximum undo depth.
|
||||
const MAX_UNDO: usize = 500;
|
||||
/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further.
|
||||
pub const UNDO_LIST_MAX: usize = 100;
|
||||
|
||||
impl Session {
|
||||
/// File name of the persisted custom dictionary inside the settings folder.
|
||||
pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
|
||||
|
||||
/// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up,
|
||||
/// with `removed` (words just taken out) left out of the merge with the file. A failed save
|
||||
/// keeps the words in memory and never fails the command (#40).
|
||||
pub(crate) fn persist_user_dictionary(&self, removed: &[String]) {
|
||||
if let Some(path) = self.user_dict_path.as_deref()
|
||||
&& let Err(e) = wordcraft_proof::save_user_dictionary(path, removed)
|
||||
{
|
||||
log::warn!("custom dictionary: save failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(doc: Document) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
@@ -425,6 +569,7 @@ impl Session {
|
||||
view: ViewState::default(),
|
||||
pending: None,
|
||||
path: None,
|
||||
user_dict_path: None,
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
pending_move: None,
|
||||
@@ -462,6 +607,7 @@ impl Session {
|
||||
math_normal_text: false,
|
||||
also_selected: Vec::new(),
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
@@ -469,6 +615,7 @@ impl Session {
|
||||
password: None,
|
||||
column: None,
|
||||
column_mode: false,
|
||||
widened: None,
|
||||
edit_marks: Vec::new(),
|
||||
go_back_step: 0,
|
||||
}
|
||||
@@ -509,7 +656,20 @@ impl Session {
|
||||
/// The line pieces of the current column selection, if there is one (the selection hasn't
|
||||
/// moved since the block was made).
|
||||
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
|
||||
// Cells that are one run of the text are an ordinary stream selection.
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice())
|
||||
}
|
||||
|
||||
/// The whole table cells selected, if the selection is that (see [`CellRange`]).
|
||||
pub fn cell_range(&self) -> Option<&CellRange> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref())
|
||||
}
|
||||
|
||||
/// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where
|
||||
/// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds
|
||||
/// those).
|
||||
pub fn sel_ends(&self) -> &Selection {
|
||||
self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends)
|
||||
}
|
||||
|
||||
/// Document revision (bumped by every change).
|
||||
@@ -566,36 +726,45 @@ impl Session {
|
||||
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let ww = self.view.web_width;
|
||||
let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode };
|
||||
let width = if self.view.read_mode { self.view.read_width } else { self.view.web_width };
|
||||
let height = if self.view.read_mode { self.view.read_height } else { 0.0 };
|
||||
let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None };
|
||||
// Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so
|
||||
// a view change that moves them in or out of the text lays it out again.
|
||||
let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions();
|
||||
let deleted = (hide_deleted, self.revisions_in_balloons());
|
||||
let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
if let Some((r, w, m, l, pf, o, hd)) = &self.layout
|
||||
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
if let Some((r, w, h, m, sp, l, pf, o, hd, n)) = &self.layout
|
||||
&& *r == self.rev
|
||||
&& (*w == ww || self.view.mode == ViewMode::Print)
|
||||
&& *m == self.view.mode
|
||||
&& (*w == width || mode == ViewMode::Print)
|
||||
&& *h == height
|
||||
&& *m == mode
|
||||
&& *sp == spacing
|
||||
&& *pf == self.view.proofing
|
||||
&& *n == self.view.numeral
|
||||
&& *hd == deleted
|
||||
&& *o == outline
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
let opts = LayoutOptions {
|
||||
view: self.view.mode,
|
||||
web_width: ww,
|
||||
view: mode,
|
||||
web_width: width,
|
||||
read_height: height,
|
||||
read_line_spacing: spacing.map(f32::from_bits),
|
||||
show_hidden: self.view.marks || self.prefs.marks.hidden,
|
||||
hide_deleted,
|
||||
deletions_in_balloons: deleted.1,
|
||||
proofing: self.view.proofing,
|
||||
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
|
||||
comment_balloons: false,
|
||||
field_codes: self.view.field_codes,
|
||||
outline: outline.clone(),
|
||||
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
|
||||
};
|
||||
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
|
||||
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted));
|
||||
self.layout = Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral));
|
||||
l
|
||||
}
|
||||
/// A layout for output (PDF, images, print): no proofing marks, print view.
|
||||
@@ -640,7 +809,13 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
self.typing_open = label == "Typing";
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone(), pending: self.pending.clone() }));
|
||||
self.history.push(Arc::new(Undo {
|
||||
label: label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
sel: self.sel.clone(),
|
||||
pending: self.pending.clone(),
|
||||
column: self.column.clone(),
|
||||
}));
|
||||
if self.history.len() > MAX_UNDO {
|
||||
self.history.remove(0);
|
||||
self.undo_evicted += 1;
|
||||
@@ -650,7 +825,7 @@ impl Session {
|
||||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||||
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone() }));
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None }));
|
||||
if self.history.len() > MAX_UNDO {
|
||||
self.history.remove(0);
|
||||
self.undo_evicted += 1;
|
||||
@@ -698,6 +873,21 @@ impl Session {
|
||||
pub fn undo_labels(&self) -> Vec<String> {
|
||||
self.history.iter().rev().map(|u| u.label.clone()).collect()
|
||||
}
|
||||
/// The Undo button's list (#701): the undo steps' labels (English, as commands name them),
|
||||
/// newest first, at most [`UNDO_LIST_MAX`].
|
||||
pub fn undo_history(&self) -> Vec<&str> {
|
||||
self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect()
|
||||
}
|
||||
/// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701).
|
||||
/// More than the undo depth undoes everything; a step that would take the document's
|
||||
/// protection away stops it (only Stop Protection lifts it). Returns the steps undone.
|
||||
pub fn undo_steps(&mut self, count: usize) -> usize {
|
||||
let mut done = 0;
|
||||
while done < count && !self.step_lifts_protection(false) && self.undo() {
|
||||
done += 1;
|
||||
}
|
||||
done
|
||||
}
|
||||
pub fn undo(&mut self) -> bool {
|
||||
self.typing_open = false;
|
||||
let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false };
|
||||
@@ -706,6 +896,7 @@ impl Session {
|
||||
doc: std::mem::replace(&mut self.doc, u.doc),
|
||||
sel: std::mem::replace(&mut self.sel, u.sel),
|
||||
pending: std::mem::replace(&mut self.pending, u.pending),
|
||||
column: std::mem::replace(&mut self.column, u.column),
|
||||
};
|
||||
self.redo.push(Arc::new(cur));
|
||||
self.touch();
|
||||
@@ -724,6 +915,7 @@ impl Session {
|
||||
doc: std::mem::replace(&mut self.doc, u.doc),
|
||||
sel: std::mem::replace(&mut self.sel, u.sel),
|
||||
pending: std::mem::replace(&mut self.pending, u.pending),
|
||||
column: std::mem::replace(&mut self.column, u.column),
|
||||
};
|
||||
self.history.push(Arc::new(cur));
|
||||
self.touch();
|
||||
@@ -736,6 +928,31 @@ impl Session {
|
||||
self.typing_open = false;
|
||||
}
|
||||
|
||||
/// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the
|
||||
/// system's.
|
||||
pub fn region(&self) -> wordcraft_geom::Region {
|
||||
match self.prefs.region.as_deref() {
|
||||
Some(tag) => wordcraft_geom::Region::from_tag(tag),
|
||||
None => self.system_region.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
|
||||
pub fn units(&self) -> wordcraft_geom::Unit {
|
||||
self.prefs.units.unwrap_or_else(|| self.region().units())
|
||||
}
|
||||
|
||||
/// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland).
|
||||
pub fn format_length(&self, pt: f32) -> String {
|
||||
self.region().format(self.units(), pt)
|
||||
}
|
||||
|
||||
/// A new blank document (the Normal template) on the region's paper: Letter in the US and
|
||||
/// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round.
|
||||
pub fn blank_document(&self) -> Document {
|
||||
blank_document(&self.region())
|
||||
}
|
||||
|
||||
/// Replace the document (open/new).
|
||||
pub fn set_document(&mut self, doc: Document) {
|
||||
self.document_id = self.document_id.wrapping_add(1);
|
||||
@@ -748,6 +965,7 @@ impl Session {
|
||||
self.also_selected.clear();
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
self.edit_marks.clear();
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
@@ -780,6 +998,8 @@ impl Session {
|
||||
view: _,
|
||||
pending: _,
|
||||
path: _,
|
||||
// The custom dictionary file, like the file path: host wiring, not an edit.
|
||||
user_dict_path: _,
|
||||
dirty,
|
||||
clipboard: _,
|
||||
// Like the clipboard: what the next paste can make a move of.
|
||||
@@ -815,6 +1035,7 @@ impl Session {
|
||||
document_id: _,
|
||||
read_aloud: _,
|
||||
prefs: _,
|
||||
system_region: _,
|
||||
keymap: _,
|
||||
ribbon: _,
|
||||
// A preference for new documents, like `prefs`: not part of this document.
|
||||
@@ -826,9 +1047,11 @@ impl Session {
|
||||
// The save password, like the file path: not an edit to the document.
|
||||
password: _,
|
||||
also_selected: _,
|
||||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
column_mode: _,
|
||||
widened: _,
|
||||
// Go Back's places, like the caret history: not part of the document.
|
||||
edit_marks: _,
|
||||
go_back_step: _,
|
||||
@@ -960,7 +1183,8 @@ impl Session {
|
||||
self.typing_open = false;
|
||||
}
|
||||
// Only the paste right after a tracked cut makes it a move.
|
||||
if (spec.mutates || matches!(id, "edit.undo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") {
|
||||
if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial")
|
||||
{
|
||||
self.pending_move = None;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
@@ -1059,7 +1283,21 @@ impl Session {
|
||||
|
||||
/// Selected plain text.
|
||||
pub fn selected_text(&self) -> String {
|
||||
// A column block or whole cells that aren't one run of the text: each piece's own text,
|
||||
// a line each, so the status bar and Word Count count those cells only.
|
||||
if let Some(segs) = self.column_segments() {
|
||||
return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::<Vec<_>>().join("\n");
|
||||
}
|
||||
let (a, b) = self.sel.ordered();
|
||||
self.doc.copy_range(&a, &b).plain_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// A new blank document on `region`'s paper (see [`Session::blank_document`]).
|
||||
pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
|
||||
let mut d = Document::new();
|
||||
let (_, w, h) = region.paper();
|
||||
d.last_section.page_w = w;
|
||||
d.last_section.page_h = h;
|
||||
d
|
||||
}
|
||||
|
||||
+645
-2
@@ -125,6 +125,66 @@ fn reset_shortcuts_remove_direct_formatting_but_keep_styles() {
|
||||
assert_eq!(text(&s), "Hello world—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_dictionary_words_persist_through_their_file() {
|
||||
// #40: adding a word through a command writes it to the session's file, and loading
|
||||
// that file makes the word known (only the desktop app wires this up).
|
||||
let dir = std::env::temp_dir().join(format!("wc-engdict-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("custom-dictionary.txt");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut s = s();
|
||||
s.user_dict_path = Some(path.clone());
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "zxqengpersist"}));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(saved.lines().any(|l| l == "zxqengpersist"), "the word reaches the file: {saved:?}");
|
||||
run(&mut s, "file.options", json!({"removeWords": ["zxqengpersist"]}));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(!saved.lines().any(|l| l == "zxqengpersist"), "removal reaches the file too: {saved:?}");
|
||||
|
||||
// Ignore All is for this session only, as in Word: the word is accepted, never saved.
|
||||
assert!(!wordcraft_proof::is_correct("zxqengignored"));
|
||||
run(&mut s, "review.ignoreAll", json!({"word": "zxqengignored"}));
|
||||
assert!(wordcraft_proof::is_correct("zxqengignored"));
|
||||
assert!(!wordcraft_proof::user_dictionary().contains(&"zxqengignored".to_string()));
|
||||
let saved = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(!saved.contains("zxqengignored"), "Ignore All doesn't reach the file: {saved:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_dictionary_takes_only_words_the_file_keeps() {
|
||||
// #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load.
|
||||
let mut s = s();
|
||||
for bad in ["two words", "tab\tword", "bell\u{7}", &"x".repeat(65)] {
|
||||
assert!(s.run("review.addToDictionary", &json!({"word": bad})).is_err(), "{bad:?}");
|
||||
assert!(!wordcraft_proof::user_dictionary().iter().any(|w| w == &bad.to_lowercase()));
|
||||
}
|
||||
run(&mut s, "review.addToDictionary", json!({"word": " zxqengtrimmed "}));
|
||||
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengtrimmed".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_english_runs_are_not_proofed() {
|
||||
// #100: runs tagged with a proofing language that has no dictionary are skipped
|
||||
// by both proofing paths; English still is proofed.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "ąžuolas labas"}));
|
||||
let before = run(&mut s, "review.issues", json!({}));
|
||||
assert!(!before.as_array().map(|a| a.is_empty()).unwrap_or(true), "default language flags the words: {before}");
|
||||
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "lt-LT"}));
|
||||
let after = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(after, json!([]), "Lithuanian runs are skipped: {after}");
|
||||
|
||||
// English typos are still caught.
|
||||
let mut e = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut e, "text.insert", json!({"text": "Hello helo world"}));
|
||||
let en = run(&mut e, "review.issues", json!({}));
|
||||
assert_eq!(en.as_array().map(|a| a.len()).unwrap_or(0), 1, "English typo still flagged: {en}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_enter_undo() {
|
||||
let mut s = s();
|
||||
@@ -143,6 +203,68 @@ fn typing_enter_undo() {
|
||||
assert_eq!(text(&s), "Hello world");
|
||||
}
|
||||
|
||||
/// The Undo button's list (#701): several steps undone at once, newest first.
|
||||
#[test]
|
||||
fn undo_to_takes_several_steps_back_at_once() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "World"}));
|
||||
let h = run(&mut s, "edit.undoHistory", json!({}));
|
||||
assert_eq!(h["undo"], json!(["Typing", "New Paragraph", "Bold", "Typing"]));
|
||||
assert_eq!(h["depth"], 4);
|
||||
assert_eq!(s.undo_history(), ["Typing", "New Paragraph", "Bold", "Typing"]);
|
||||
// Three steps in one command: World, the paragraph and the bold go; Hello stays.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 3}));
|
||||
assert_eq!(r["undone"], 3);
|
||||
assert_eq!(r["depth"], 1);
|
||||
assert_eq!(text(&s), "Hello");
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(1).bold), Some(None));
|
||||
assert_eq!(s.undo_history(), ["Typing"]);
|
||||
// Each undone step is one redo step.
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Hello\nWorld");
|
||||
// More than the depth undoes everything.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 1e300}));
|
||||
assert_eq!(r["undone"], 4);
|
||||
assert_eq!(text(&s), "");
|
||||
assert!(!s.can_undo());
|
||||
assert!(s.run("edit.undoTo", &json!({"count": 1})).is_err(), "nothing to undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_to_rejects_bad_counts_and_leaves_the_document_alone() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
let before = (s.doc.clone(), s.undo_depth());
|
||||
for junk in
|
||||
[json!({}), json!(null), json!({"count": 0}), json!({"count": -3}), json!({"count": "2"}), json!({"count": [2]}), json!({"count": -1e308})]
|
||||
{
|
||||
assert!(s.run("edit.undoTo", &junk).is_err(), "{junk}");
|
||||
assert_eq!((s.doc.clone(), s.undo_depth()), before, "{junk}");
|
||||
}
|
||||
// A fraction counts whole steps.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 1.9}));
|
||||
assert_eq!(r["undone"], 1);
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_history_is_capped() {
|
||||
let mut s = s();
|
||||
for i in 0..(crate::UNDO_LIST_MAX + 20) {
|
||||
run(&mut s, "para.indents", json!({"left": i as f64}));
|
||||
}
|
||||
assert_eq!(s.undo_history().len(), crate::UNDO_LIST_MAX);
|
||||
assert_eq!(run(&mut s, "edit.undoHistory", json!({}))["undo"].as_array().map(Vec::len), Some(crate::UNDO_LIST_MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_commands_are_one_undo_step() {
|
||||
let mut s = s();
|
||||
@@ -769,6 +891,92 @@ fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
assert!(text(&s).starts_with("one\ntwo"));
|
||||
}
|
||||
|
||||
/// Character formatting at a body offset of the first paragraph.
|
||||
fn props_at(s: &Session, off: usize) -> wordcraft_doc::CharProps {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
|
||||
s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Paste of another program's formatted copy keeps its formatting (#687); Merge Formatting keeps
|
||||
/// only its emphasis, in the destination's font; Keep Text Only drops it all.
|
||||
#[test]
|
||||
fn paste_keeps_source_formatting_from_other_programs() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let html = "<p><span style=\"font-family:'Courier New';color:#ff0000\">red <b>bold</b></span></p>";
|
||||
let clip = json!({"text": "red bold", "html": html});
|
||||
run(&mut s, "edit.paste", clip.clone());
|
||||
assert_eq!(text(&s), "xred bold");
|
||||
assert_eq!(props_at(&s, 1).font.as_deref(), Some("Courier New"));
|
||||
assert!(props_at(&s, 1).color.is_some());
|
||||
assert!(!bold_at(&s, 0, 1) && bold_at(&s, 0, 5));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "x");
|
||||
run(&mut s, "edit.pasteMerge", clip.clone());
|
||||
assert_eq!(text(&s), "xred bold");
|
||||
assert_eq!(props_at(&s, 1).font, None, "Merge Formatting takes the destination's font");
|
||||
assert_eq!(props_at(&s, 1).color, None);
|
||||
assert!(bold_at(&s, 0, 5), "and keeps the emphasis");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.pasteText", clip);
|
||||
assert_eq!(text(&s), "xred bold");
|
||||
assert!(!bold_at(&s, 0, 5));
|
||||
|
||||
// Windows' "HTML Format" carries a header with the fragment's byte offsets.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
let body = "<html><body><!--StartFragment--><b>win</b><!--EndFragment--></body></html>";
|
||||
let head = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:SSSSSSSSSS\r\nEndFragment:EEEEEEEEEE\r\n";
|
||||
let start = head.len() + body.find("<b>").unwrap();
|
||||
let end = head.len() + body.find("<!--EndFragment").unwrap();
|
||||
let cf = format!("{head}{body}").replace("SSSSSSSSSS", &format!("{start:010}")).replace("EEEEEEEEEE", &format!("{end:010}"));
|
||||
run(&mut s, "edit.paste", json!({"text": "win", "html": cf}));
|
||||
assert_eq!(text(&s), "win");
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
assert_eq!(crate::cmd::paste::html_fragment("Version:1\r\nStartFragment:99999\r\nEndFragment:5\r\n<i>x</i>"), "<i>x</i>");
|
||||
|
||||
// RTF when there is no HTML.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.paste", json!({"text": "bold then", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
|
||||
assert!(text(&s).starts_with("bold"), "{}", text(&s));
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
|
||||
// HTML with nothing to paste (a web picture by address) or too large: the text is pasted.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.paste", json!({"text": "alt", "html": "<img src='https://example.com/a.png'>"}));
|
||||
assert_eq!(text(&s), "alt");
|
||||
let huge = format!("<b>{}</b>", "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES));
|
||||
run(&mut s, "edit.paste", json!({"text": "!", "html": huge}));
|
||||
assert_eq!(text(&s), "alt!");
|
||||
assert!(s.run("edit.paste", &json!({"html": "<p> </p>"})).is_err(), "nothing to paste is an error");
|
||||
assert!(s.run("edit.pasteMerge", &json!({"rtf": "not rtf"})).is_err());
|
||||
assert!(s.run("edit.pasteText", &json!({})).is_err());
|
||||
|
||||
// Our own copy still wins while the system clipboard holds its text.
|
||||
run(&mut s, "document.setText", json!({"text": "Own"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.italic", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.paste", json!({"text": "Own"}));
|
||||
assert_eq!(text(&s), "OwnOwn");
|
||||
assert_eq!(props_at(&s, 4).italic, Some(true));
|
||||
}
|
||||
|
||||
/// Paste Special lists a picture on the system clipboard and pastes it (#687).
|
||||
#[test]
|
||||
fn paste_special_lists_and_pastes_a_picture() {
|
||||
let mut s = s();
|
||||
let png = crate::cmd::insert::base64_encode(&tiny_png());
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"picture": png, "html": "<img src='x.png'>"}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["html", "picture"]);
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "picture", "picture": png}));
|
||||
assert_eq!(s.doc.media.len(), 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "picture", "picture": "%%%"})).is_err());
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "picture"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
|
||||
let mut s = s();
|
||||
@@ -2428,12 +2636,13 @@ fn style_inspector_reports_and_clears_levels() {
|
||||
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
|
||||
assert!(r["character"]["style"].is_null());
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]);
|
||||
|
||||
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
|
||||
run(&mut s, "format.size", json!({"size": 20}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]);
|
||||
|
||||
// Character style level.
|
||||
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
|
||||
@@ -2614,6 +2823,113 @@ fn page_and_table_gridlines_toggle_independently() {
|
||||
assert_eq!(state["tableGridlines"], false);
|
||||
}
|
||||
|
||||
/// Issue #768: zooming by percent after One Page or Page Width keeps one page per row.
|
||||
#[test]
|
||||
fn zoom_after_one_page_keeps_single_column() {
|
||||
let mut s = s();
|
||||
run(&mut s, "view.onePage", json!({}));
|
||||
assert!(s.view.single_column);
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert!(s.view.fit.is_empty() && s.view.single_column);
|
||||
run(&mut s, "view.zoom", json!({"value": 50}));
|
||||
assert!(s.view.single_column);
|
||||
run(&mut s, "view.multiplePages", json!({}));
|
||||
assert!(!s.view.single_column);
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert!(!s.view.single_column);
|
||||
run(&mut s, "view.pageWidth", json!({}));
|
||||
run(&mut s, "view.sideToSide", json!({}));
|
||||
assert!(!s.view.single_column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_pane_opens_without_changing_view_mode() {
|
||||
// Reading Mode's heading navigation is the Navigation pane; Outline is an editing view (#493).
|
||||
let mut s = s();
|
||||
for command in ["view.printLayout", "view.webLayout", "view.readMode"] {
|
||||
run(&mut s, command, json!({}));
|
||||
let before = (s.view.mode, s.view.read_mode);
|
||||
s.view.nav_pane = false;
|
||||
run(&mut s, "view.navigationPane", json!({"value": true}));
|
||||
assert!(s.view.nav_pane);
|
||||
assert_eq!((s.view.mode, s.view.read_mode), before, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "A long paragraph for reading. ".repeat(200)}));
|
||||
let original = s.doc.clone();
|
||||
let print = s.export_layout();
|
||||
let dirty = s.dirty;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert!(s.view.read_mode);
|
||||
s.view.read_width = 240.0;
|
||||
s.view.read_height = 420.0;
|
||||
let columns = s.layout();
|
||||
assert!(columns.pages.len() > print.pages.len());
|
||||
assert!(columns.pages.iter().all(|p| p.w == 240.0 && p.h == 420.0));
|
||||
s.view.read_height = 260.0;
|
||||
assert!(s.layout().pages.len() > columns.pages.len(), "height is part of the layout cache key");
|
||||
s.view.read_width = 300.0;
|
||||
assert_eq!(s.layout().pages[0].w, 300.0, "width is part of the layout cache key");
|
||||
let serialized = serde_json::to_value(&s.view).unwrap();
|
||||
assert!(serialized.get("readWidth").is_none() && serialized.get("readHeight").is_none());
|
||||
let restored: crate::ViewState = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!((restored.read_width, restored.read_height), (300.0, 500.0));
|
||||
run(&mut s, "view.readColumnWidth", json!({"value": "page"}));
|
||||
run(&mut s, "view.readTextSize", json!({"value": 125}));
|
||||
let default_spacing_pages = s.layout().pages.len();
|
||||
run(&mut s, "view.readLineSpacing", json!({"value": 2.0}));
|
||||
assert_eq!(s.view.read_column_width, Some(612.0));
|
||||
assert_eq!(s.view.read_zoom, 1.25);
|
||||
assert_eq!(s.view.read_line_spacing, Some(2.0));
|
||||
let spaced = s.layout();
|
||||
assert!(spaced.pages.len() > default_spacing_pages, "line spacing must invalidate cached pagination");
|
||||
assert!(s.run("view.readTextSize", &json!({"value": -10})).is_err());
|
||||
assert!(s.run("view.readLineSpacing", &json!({"value": 0.01})).is_err());
|
||||
assert!(s.run("view.readColumnWidth", &json!({"value": "giant"})).is_err());
|
||||
let serialized = serde_json::to_value(&s.view).unwrap();
|
||||
for key in ["readColumnWidth", "readZoom", "readLineSpacing"] {
|
||||
assert!(serialized.get(key).is_none());
|
||||
}
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
assert!(!s.view.read_mode);
|
||||
assert_eq!(s.layout().pages[0].w, print.pages[0].w);
|
||||
assert_eq!(s.export_layout().pages.len(), print.pages.len());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_column_width, None);
|
||||
assert_eq!(s.view.read_zoom, 1.0);
|
||||
assert_eq!(s.view.read_line_spacing, None);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
#[test]
|
||||
fn numeral_mode_sets_gets_and_rejects_junk() {
|
||||
use wordcraft_layout::NumeralMode;
|
||||
let mut s = s();
|
||||
assert_eq!(s.view.numeral, NumeralMode::Arabic, "Western digits by default");
|
||||
let r = run(&mut s, "view.numeral", json!({"value": "hindi"}));
|
||||
assert_eq!(r["value"], "hindi");
|
||||
assert_eq!(s.view.numeral, NumeralMode::Hindi);
|
||||
let r = run(&mut s, "view.numeral", json!({}));
|
||||
assert_eq!(r["value"], "hindi", "no params reads the current mode");
|
||||
run(&mut s, "view.numeral", json!({"value": "Context"}));
|
||||
assert_eq!(s.view.numeral, NumeralMode::Context, "mode names ignore case");
|
||||
assert!(s.run("view.numeral", &json!({"value": "western"})).is_err());
|
||||
assert!(s.run("view.numeral", &json!({"value": 3})).is_err());
|
||||
assert!(s.run("view.numeral", &json!({"value": ["hindi"]})).is_err());
|
||||
assert_eq!(s.view.numeral, NumeralMode::Context, "rejected values keep the mode");
|
||||
// Switching modes relayouts without disturbing the text.
|
||||
s.doc = wordcraft_doc::Document::from_text("Year 2024");
|
||||
run(&mut s, "view.numeral", json!({"value": "hindi"}));
|
||||
let _l = s.layout();
|
||||
assert_eq!(text(&s), "Year 2024");
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
|
||||
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
|
||||
/// short of the 10%–500% limits.
|
||||
@@ -3572,6 +3888,38 @@ fn enclose_characters_make_an_eq_field_and_come_back_out() {
|
||||
assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_buttons_follow_the_paragraphs_own_level() {
|
||||
use crate::cmd::para::{ListButtons, list_buttons};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "item"}));
|
||||
let state = |s: &Session| {
|
||||
let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props));
|
||||
list_buttons(&s.doc, rp.and_then(|r| r.numbering))
|
||||
};
|
||||
assert_eq!(state(&s), ListButtons::default(), "plain text: no list button on");
|
||||
run(&mut s, "para.bullets", json!({}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: true, numbering: false, multilevel: false });
|
||||
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
|
||||
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
|
||||
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
|
||||
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
run(&mut s, "para.listLevel", json!({"level": 1}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }, "1. a. i. is not multilevel");
|
||||
// Legal numbering (1. 1.1. 1.1.1.) numbers levels together, even on its first level. (Its
|
||||
// first level reads like "1." numbering, which the button would toggle off, so start plain.)
|
||||
run(&mut s, "para.numbering", json!({"off": true}));
|
||||
run(&mut s, "para.multilevel", json!({"kind": "legal"}));
|
||||
run(&mut s, "para.listLevel", json!({"level": 0}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: true });
|
||||
run(&mut s, "para.listLevel", json!({"level": 2}));
|
||||
assert!(state(&s).multilevel);
|
||||
run(&mut s, "para.numbering", json!({"off": true}));
|
||||
assert_eq!(state(&s), ListButtons::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() {
|
||||
// Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it.
|
||||
@@ -3701,6 +4049,50 @@ fn polish_test_dictionary() {
|
||||
wordcraft_proof::pl::install_dictionary(aff, dic).unwrap();
|
||||
}
|
||||
|
||||
/// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries
|
||||
/// are installed on the system, never bundled; every Portuguese test installs this same one.
|
||||
fn portuguese_test_dictionary() {
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
|
||||
let dic = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
|
||||
wordcraft_proof::pt::install_dictionary(aff, dic).unwrap();
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Portuguese end to end: the language makes
|
||||
/// the document's words Portuguese, F7 and the issue list use the Portuguese dictionary and
|
||||
/// grammar, suggestions come from it, and a suggestion fixes the text.
|
||||
#[test]
|
||||
fn portuguese_proofing_through_the_review_commands() {
|
||||
portuguese_test_dictionary();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Saudade coraçao, e ele ele disse."}));
|
||||
// English by default: the Portuguese words are English misspellings.
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "pt_br", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "the tag is normalized");
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pt-BR");
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
let has = |text: &str, kind: &str, sugg: &str| {
|
||||
arr.iter()
|
||||
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pt" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||||
};
|
||||
assert!(has("coraçao", "spelling", "coração"), "{issues}");
|
||||
assert!(has("ele ele", "grammar", "ele"), "{issues}");
|
||||
assert_eq!(arr.len(), 2, "{issues}");
|
||||
assert!(arr.iter().any(|i| i["message"] == "Palavra repetida: \"ele\""), "{issues}");
|
||||
// F7 from the start selects the first issue; Change applies a suggestion.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let first = run(&mut s, "review.spelling", json!({}));
|
||||
assert_eq!(first["text"], "coraçao");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "coração"}));
|
||||
assert!(text(&s).starts_with("Saudade coração,"), "{}", text(&s));
|
||||
// Add to Dictionary works for Portuguese words too.
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "coraçao"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
@@ -4069,6 +4461,48 @@ fn outline_collapse_hides_descendants_and_expand_restores() {
|
||||
assert_eq!(s.selected_text(), "One\na\nTwo\nb");
|
||||
}
|
||||
|
||||
/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at
|
||||
/// their pixel size are fitted to the text width when the file is opened (and when pasted),
|
||||
/// keeping their proportions; small pictures keep their size.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn imported_pictures_fit_the_text_width() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let mut b = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
cmd::insert::base64_encode(&b)
|
||||
};
|
||||
let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> {
|
||||
doc.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Image { w, h, .. } => Some((*w, *h)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let md = format!("\n\n\n", png(1200, 300), png(40, 20));
|
||||
let doc = crate::io::open_bytes("p.md", md.as_bytes()).unwrap();
|
||||
let max_w = crate::io::text_width(&doc);
|
||||
let got = sizes(&doc);
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert!((got[0].0 - max_w).abs() < 0.01, "wide picture fits: {got:?} (text width {max_w})");
|
||||
assert!((got[0].1 - max_w / 4.0).abs() < 0.01, "and keeps its proportions: {got:?}");
|
||||
assert!((got[1].0 - 30.0).abs() < 0.01 && (got[1].1 - 15.0).abs() < 0.01, "small picture keeps its size: {got:?}");
|
||||
|
||||
// Pasting HTML fits our text width too.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let html = format!("<p><img src=\"data:image/png;base64,{}\"></p>", png(1200, 300));
|
||||
s.run("edit.pasteSpecial", &json!({"as": "html", "html": html})).unwrap();
|
||||
let max_w = crate::io::text_width(&s.doc);
|
||||
let got = sizes(&s.doc);
|
||||
assert!(got.iter().any(|(w, _)| (w - max_w).abs() < 0.01), "pasted picture fits: {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_font_sets_the_asian_and_latin_fonts_separately() {
|
||||
// #505: Word's Font dialog sets an Asian text font and a Latin text font of their own.
|
||||
@@ -4093,3 +4527,212 @@ fn format_font_sets_the_asian_and_latin_fonts_separately() {
|
||||
assert_eq!(fonts(&s), before);
|
||||
assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err());
|
||||
}
|
||||
|
||||
/// A one-column table (no header row) holding `cells`, in a document whose default language
|
||||
/// is `lang`; the caret ends in the last cell.
|
||||
fn column_table(lang: &str, cells: &[&str]) -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": lang, "default": true}));
|
||||
run(&mut s, "insert.table", json!({"rows": cells.len(), "cols": 1}));
|
||||
for (k, c) in cells.iter().enumerate() {
|
||||
if k > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
if !c.is_empty() {
|
||||
run(&mut s, "text.insert", json!({"text": c}));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn column_texts(s: &Session) -> Vec<String> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
|
||||
#[test]
|
||||
fn table_sort_reads_decimal_comma_in_polish() {
|
||||
let mut s = column_table("pl-PL", &["12", "1,5", "2"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["1,5", "2", "12"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false, "descending": true}));
|
||||
assert_eq!(column_texts(&s), ["12", "2", "1,5"]);
|
||||
// English keeps the comma as a thousands separator.
|
||||
let mut s = column_table("en-US", &["1,250", "12.5", "2"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["2", "12.5", "1,250"]);
|
||||
}
|
||||
|
||||
/// #836: table formulas read each cell in its language and write the result with its decimal
|
||||
/// separator.
|
||||
#[test]
|
||||
fn table_formula_reads_and_writes_decimal_comma_in_polish() {
|
||||
let mut s = column_table("pl-PL", &["3,50", "2,25", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
|
||||
let mut s = column_table("pl-PL", &["1 250,00", "100", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350");
|
||||
let mut s = column_table("pl-PL", &["1\u{a0}250,50 zł", "100", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350,50");
|
||||
let mut s = column_table("en-US", &["$1,250.50", "3.25", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1253.75");
|
||||
}
|
||||
|
||||
/// #837: text sorts follow the language's alphabet: Polish `ł` after `l`, `ć` after `c`.
|
||||
#[test]
|
||||
fn table_and_paragraph_sort_use_polish_alphabet() {
|
||||
let mut s = column_table("pl-PL", &["Zakopane", "Łódź", "Kraków"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Kraków", "Łódź", "Zakopane"]);
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("zebra\nćma\ndom"));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "para.sort", json!({}));
|
||||
assert_eq!(text(&s), "ćma\ndom\nzebra");
|
||||
// Swedish puts å ä ö after z; German sorts ä with a.
|
||||
let mut s = column_table("sv-SE", &["ödla", "zebra", "åka", "apa"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["apa", "zebra", "åka", "ödla"]);
|
||||
let mut s = column_table("de-DE", &["Zug", "Äpfel", "Bach"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Äpfel", "Bach", "Zug"]);
|
||||
}
|
||||
|
||||
/// #837: index entries and letter headings follow the language: `Łódź` files under its own
|
||||
/// `Ł` heading between `K` and `Z` in Polish.
|
||||
#[test]
|
||||
fn index_sorts_polish_entries_and_headings() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Zakopane.\nŁódź.\nKraków."));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
for w in ["Zakopane", "Łódź", "Kraków"] {
|
||||
run(&mut s, "select.text", json!({"text": w}));
|
||||
run(&mut s, "references.markEntry", json!({}));
|
||||
}
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "references.index", json!({}));
|
||||
let lines: Vec<String> =
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("ListEntry")).map(|p| p.plain_text()).collect();
|
||||
let order: Vec<&str> = lines.iter().map(|l| l.split([',', '\t']).next().unwrap_or("")).collect();
|
||||
let pos = |w: &str| order.iter().position(|x| *x == w).unwrap_or(usize::MAX);
|
||||
assert!(pos("Kraków") < pos("Łódź") && pos("Łódź") < pos("Zakopane"), "{lines:?}");
|
||||
}
|
||||
|
||||
/// Review of #844: a numbered column (`1.`, `2.`, `10.`) sorts as numbers; text with no language
|
||||
/// at all follows the editing language (a Polish interface reads `3,50` as 3.5).
|
||||
#[test]
|
||||
fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
let mut t = column_table("pl-PL", &["10.", "2.", "1."]);
|
||||
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&t), ["1.", "2.", "10."]);
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "3,50"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "2,25"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
|
||||
}
|
||||
|
||||
/// Review of #844: the bibliography lists Polish authors in the Polish alphabet.
|
||||
#[test]
|
||||
fn bibliography_sorts_polish_authors() {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
for author in ["Mazur, Jan", "Łukasiewicz, Jan", "Lis, Anna"] {
|
||||
run(&mut s, "references.citation", json!({"source": {"author": author, "title": format!("Książka {author}"), "year": "2020"}}));
|
||||
run(&mut s, "text.insert", json!({"text": " "}));
|
||||
}
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "references.bibliography", json!({}));
|
||||
let all = text(&s);
|
||||
// The list, not the citations before it.
|
||||
let t = all.split_once("References").map_or(all.as_str(), |(_, list)| list);
|
||||
let at = |name: &str| t.find(&format!("{name}, ")).unwrap_or_else(|| panic!("{name} in {all}"));
|
||||
assert!(at("Lis") < at("Łukasiewicz") && at("Łukasiewicz") < at("Mazur"), "{t}");
|
||||
}
|
||||
|
||||
/// A new blank document takes its paper from the region, and lengths are shown in the region's
|
||||
/// unit with its decimal separator; an explicit Measurement units choice still wins (#838).
|
||||
#[test]
|
||||
fn new_documents_follow_the_region() {
|
||||
// Poland: A4, centimetres, decimal comma.
|
||||
let mut s = s();
|
||||
run(&mut s, "file.options", json!({"region": "pl-PL"}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let sp = &s.doc.last_section;
|
||||
assert_eq!(wordcraft_geom::paper_name(sp.page_w, sp.page_h), Some("A4"));
|
||||
assert_eq!(sp.margin_left, 72.0);
|
||||
assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters);
|
||||
assert_eq!(s.format_length(sp.margin_left), "2,54 cm");
|
||||
let o = run(&mut s, "file.options", json!({"pane": "advanced", "units": "auto"}));
|
||||
assert_eq!((o["units"].as_str(), o["unitsFromRegion"].as_bool()), (Some("centimeters"), Some(true)));
|
||||
assert_eq!((o["region"]["paper"].as_str(), o["region"]["decimal"].as_str()), (Some("A4"), Some(",")));
|
||||
|
||||
// The user's own choice wins over the region, and `auto` gives the region's back.
|
||||
run(&mut s, "file.options", json!({"units": "inches"}));
|
||||
assert_eq!(s.units(), wordcraft_geom::Unit::Inches);
|
||||
assert_eq!(s.format_length(108.0), "1,5\"");
|
||||
run(&mut s, "file.options", json!({"units": "auto"}));
|
||||
assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters);
|
||||
|
||||
// A chosen region is a saved preference over the system's; `auto` returns to the system's.
|
||||
assert_eq!(s.prefs.region.as_deref(), Some("pl-PL"));
|
||||
let saved: serde_json::Value = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["region"], "pl-PL");
|
||||
s.system_region = wordcraft_geom::Region::from_tag("ja-JP");
|
||||
assert_eq!(s.region().territory, "PL", "the choice wins over the system");
|
||||
let o = run(&mut s, "file.options", json!({"region": "auto"}));
|
||||
assert_eq!((o["regionFromSystem"].as_bool(), o["region"]["territory"].as_str()), (Some(true), Some("JP")));
|
||||
assert_eq!(s.prefs.region, None);
|
||||
run(&mut s, "file.options", json!({"region": "de_AT.UTF-8@euro"}));
|
||||
assert_eq!(s.prefs.region.as_deref(), Some("de-AT"), "kept as a short tag");
|
||||
run(&mut s, "file.options", json!({"region": ""}));
|
||||
assert_eq!(s.prefs.region, None);
|
||||
|
||||
// The United States: Letter and inches.
|
||||
let mut us = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut us, "file.options", json!({"region": "en-US"}));
|
||||
run(&mut us, "file.new", json!({"template": "blank"}));
|
||||
let sp = &us.doc.last_section;
|
||||
assert_eq!((sp.page_w, sp.page_h), (612.0, 792.0));
|
||||
assert_eq!(us.units(), wordcraft_geom::Unit::Inches);
|
||||
assert_eq!(us.format_length(72.0), "1\"");
|
||||
run(&mut us, "file.options", json!({"units": "centimeters"}));
|
||||
assert_eq!(us.format_length(72.0), "2.54 cm");
|
||||
|
||||
// Templates keep their own size; a headless session is en-US; junk is refused or read as en-US.
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut t, "file.options", json!({"region": "de-DE"}));
|
||||
run(&mut t, "file.new", json!({"template": "letter"}));
|
||||
assert_eq!(t.doc.last_section.page_w, crate::sample::letter().last_section.page_w);
|
||||
assert_eq!(Session::new(wordcraft_doc::Document::new()).blank_document().last_section.page_w, 612.0);
|
||||
assert!(t.run("file.options", &json!({"region": 7})).is_err());
|
||||
assert!(t.run("file.options", &json!({"units": "furlongs"})).is_err());
|
||||
run(&mut t, "file.options", json!({"region": "\u{0}\u{FFFF}-zz-ZZZZZZZZ"}));
|
||||
assert_eq!(t.region(), wordcraft_geom::Region::default());
|
||||
assert_eq!(t.prefs.region.as_deref(), Some("en-US"), "junk is saved as en-US");
|
||||
}
|
||||
|
||||
/// Automatic (HTML-style) paragraph spacing: `para.spacing` turns it on and off, and a number
|
||||
/// replaces it, as typing one in Word's Paragraph dialog does; Ctrl+0 and Remove Space After work
|
||||
/// on it too.
|
||||
#[test]
|
||||
fn spacing_commands_handle_automatic_spacing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Pasted from the web"}));
|
||||
let rp = |s: &Session| s.doc.styles.resolve_para(&s.doc.para_at(&s.sel.focus).unwrap().props);
|
||||
run(&mut s, "para.spacing", json!({"beforeAuto": true, "afterAuto": true}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).space_after, rp(&s).before_auto), (14.0, 14.0, true));
|
||||
run(&mut s, "para.spacing", json!({"before": 6}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).before_auto, rp(&s).after_auto), (6.0, false, true), "a number replaces Auto");
|
||||
run(&mut s, "para.removeSpaceAfter", json!({}));
|
||||
assert_eq!((rp(&s).space_after, rp(&s).after_auto), (0.0, false));
|
||||
run(&mut s, "para.spacing", json!({"beforeAuto": true}));
|
||||
run(&mut s, "para.addSpaceBefore", json!({}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space");
|
||||
assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic");
|
||||
}
|
||||
|
||||
@@ -89,6 +89,48 @@ fn font_commands_reach_persian_text() {
|
||||
assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_commands_override_inherited_complex_script_properties_explicitly() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
|
||||
let mut s = session("שלום ABC", true);
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.default_chr.bold_cs = Some(true);
|
||||
s.doc.styles.default_chr.italic_cs = Some(true);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": false}));
|
||||
run(&mut s, "format.italic", json!({"value": false}));
|
||||
run(&mut s, "format.size", json!({"size": 18}));
|
||||
for (command, expected_size) in [
|
||||
(None, 18.0),
|
||||
(Some("format.growFont"), 20.0),
|
||||
(Some("format.shrinkFont"), 18.0),
|
||||
(Some("format.growFont1"), 19.0),
|
||||
(Some("format.shrinkFont1"), 18.0),
|
||||
] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c = p.props_of_char(0);
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false)));
|
||||
let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap();
|
||||
let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0);
|
||||
let cs = d.styles.resolve_char(None, c).complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false));
|
||||
}
|
||||
// The font dialog's command keeps formatting both scripts when given one value.
|
||||
run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}}));
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c: &CharProps = p.props_of_char(0);
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true)));
|
||||
// Callers can still supply distinct values explicitly, including false.
|
||||
run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}}));
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c = p.props_of_char(0);
|
||||
assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_visually_in_rtl_text() {
|
||||
let fa = "سلام دنیا";
|
||||
@@ -183,3 +225,23 @@ fn explicit_alignment_sets_without_toggling() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_kurdish_in_new_document_advances_caret_smoothly() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let mut first_x = None;
|
||||
let mut last_x = 0.0;
|
||||
for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] {
|
||||
run(&mut s, "text.insert", json!({"text": ch}));
|
||||
let l = s.layout();
|
||||
let c = l.caret(&s.sel.focus).unwrap();
|
||||
if first_x.is_none() {
|
||||
first_x = Some(c.x);
|
||||
}
|
||||
last_x = c.x;
|
||||
// The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0).
|
||||
assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x);
|
||||
}
|
||||
assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall");
|
||||
assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left");
|
||||
}
|
||||
|
||||
@@ -104,9 +104,12 @@ fn shift_tab_and_tab_change_list_levels() {
|
||||
fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() {
|
||||
let mut s = typed("* One\nTwo");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x);
|
||||
let before = text_x(&mut s);
|
||||
typ(&mut s, "\u{8}");
|
||||
// Number gone, still indented with the list.
|
||||
// Number gone, still indented with the list: the text stays where it was.
|
||||
assert_eq!(paras(&s)[1], ("Two".to_string(), None, true));
|
||||
assert_eq!(text_x(&mut s), before);
|
||||
typ(&mut s, "\u{8}|");
|
||||
// Second Backspace: Normal paragraph, not joined to the one above.
|
||||
assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]);
|
||||
@@ -185,6 +188,48 @@ fn autocorrect_as_you_type() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
|
||||
}
|
||||
|
||||
/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays
|
||||
/// lowercase; symbols still change; English and untagged text still get "I".
|
||||
#[test]
|
||||
fn autocorrect_replacement_list_follows_the_texts_language() {
|
||||
// Polish as the document's default language.
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
typ(&mut s, "Ala i kot. kawa i herbata teh (c) ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © ");
|
||||
// Polish on the run only: the word's own language counts.
|
||||
let mut s = self::s();
|
||||
typ(&mut s, "Ala");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
typ(&mut s, " i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ");
|
||||
// English (any region) and untagged text (the default proofing language is English).
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": "en-GB", "default": true}));
|
||||
typ(&mut s, "so i think teh end ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
|
||||
let s = typed("so i think teh end ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
|
||||
// A Polish interface: a new document is Polish by its default language, and text with no
|
||||
// language at all (a .docx without `w:lang`) follows the editing language too.
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
typ(&mut s, "Ala i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
typ(&mut s, "Ala i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language");
|
||||
// `eng` is English too, as for proofing.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": "eng", "default": true}));
|
||||
typ(&mut s, "so i think ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_options_change_what_typing_does() {
|
||||
let mut s = s();
|
||||
@@ -634,6 +679,61 @@ fn shift_f5_goes_back_through_the_last_edits() {
|
||||
assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_initial_capitals_are_corrected() {
|
||||
let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine ");
|
||||
// "TEh" loses its second capital, then the replacement list fixes the typo.
|
||||
assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The ");
|
||||
// A user exception.
|
||||
let mut s = typed("");
|
||||
run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"}));
|
||||
typ(&mut s, "WCraft ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accidental_caps_lock_is_corrected() {
|
||||
let s = typed("a wORD and hELLO there. iOS stays ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalisation_fixes_undo_and_turn_off() {
|
||||
let mut s = typed("WOrd");
|
||||
typ(&mut s, " ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Word ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd ");
|
||||
let mut s = typed("x wORD");
|
||||
typ(&mut s, " ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD ");
|
||||
let mut s = typed("");
|
||||
run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false}));
|
||||
typ(&mut s, "x WOrd wORD ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_letter_of_a_table_cell_is_capitalised() {
|
||||
let cell_text = |s: &Session, c: usize| {
|
||||
s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone()))
|
||||
};
|
||||
let mut s = typed("");
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
typ(&mut s, "apples and pears\tplums ");
|
||||
assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears"));
|
||||
assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums "));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(cell_text(&s, 1).as_deref(), Some("plums "));
|
||||
let mut s = typed("");
|
||||
run(&mut s, "tools.autocorrect", json!({"capCells": false}));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
typ(&mut s, "plums ");
|
||||
assert_eq!(cell_text(&s, 0).as_deref(), Some("plums "));
|
||||
}
|
||||
|
||||
/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the
|
||||
/// typed text replaces the selection; off, it goes in front of the selection and keeps it.
|
||||
#[test]
|
||||
@@ -705,3 +805,39 @@ fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
|
||||
typ(&mut s, "A\n\nB");
|
||||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]);
|
||||
}
|
||||
|
||||
/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that
|
||||
/// already has letters, or whose style sets a direction, keeps its own.
|
||||
#[test]
|
||||
fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
|
||||
let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi);
|
||||
// Empty paragraph: the first Arabic letter flips it, the rest keep it.
|
||||
let s = typed("سلام");
|
||||
assert_eq!(bidi(&s, 0), Some(true));
|
||||
// Digits first, then Arabic: still no letters before, so it flips.
|
||||
let s = typed("1. سلام");
|
||||
assert_eq!(bidi(&s, 0), Some(true));
|
||||
// Latin first: Arabic typed later doesn't flip the paragraph.
|
||||
let s = typed("Hello سلام");
|
||||
assert_eq!(bidi(&s, 0), None);
|
||||
// A new paragraph after an RTL one starts its own detection.
|
||||
let s = typed("سلام\nHello");
|
||||
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
|
||||
// A style that sets left-to-right wins.
|
||||
let mut doc = Session::new(wordcraft_doc::Document::new());
|
||||
if let Some(st) = doc.doc.styles.get_mut("Normal") {
|
||||
st.para.bidi = Some(false);
|
||||
}
|
||||
typ(&mut doc, "سلام");
|
||||
assert_eq!(bidi(&doc, 0), None);
|
||||
// An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret
|
||||
// after the Arabic word sits near the left margin, not the right one.
|
||||
let mut doc = typed("سلام");
|
||||
let path = doc.sel.focus.path.clone();
|
||||
if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) {
|
||||
p.props.bidi = None;
|
||||
p.touch();
|
||||
}
|
||||
let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX);
|
||||
assert!(x < 300.0, "left-to-right paragraph: caret at {x}");
|
||||
}
|
||||
|
||||
+120
-5
@@ -1222,10 +1222,8 @@ impl Body<'_> {
|
||||
}
|
||||
match local(q) {
|
||||
"p" | "h" if !self.in_desc => self.end_para(),
|
||||
"span" | "a" => {
|
||||
if self.fmt.len() > usize::from(self.para.is_some()) {
|
||||
self.fmt.pop();
|
||||
}
|
||||
"span" | "a" if self.fmt.len() > usize::from(self.para.is_some()) => {
|
||||
self.fmt.pop();
|
||||
}
|
||||
"list" => {
|
||||
self.end_para();
|
||||
@@ -1406,7 +1404,63 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
|
||||
/// Parse an `.odt` package into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
Ok(model::to_doc(&parse(bytes)?))
|
||||
let mut doc = model::to_doc(&parse(bytes)?);
|
||||
if let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
&& let Some(s) = read_entry(&mut z, "styles.xml")
|
||||
{
|
||||
apply_page_layout(&s, &mut doc.last_section);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Read the page size, orientation and margins of the first master page in `styles.xml` into `sec`.
|
||||
fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) {
|
||||
let mut layouts: HashMap<String, Attrs> = HashMap::new();
|
||||
let mut cur: Option<String> = None;
|
||||
let mut master: Option<String> = None;
|
||||
events(xml, |ev| match ev {
|
||||
Ev::Start(q, a) => match local(&q) {
|
||||
"page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from),
|
||||
"page-layout-properties" => {
|
||||
if let Some(n) = &cur {
|
||||
layouts.insert(n.clone(), a);
|
||||
}
|
||||
}
|
||||
"master-page" if master.is_none() => master = get(&a, "style:page-layout-name").map(String::from),
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(q) => {
|
||||
if local(&q) == "page-layout" {
|
||||
cur = None;
|
||||
}
|
||||
}
|
||||
Ev::Text(_) => {}
|
||||
});
|
||||
let Some(a) = master.and_then(|m| layouts.remove(&m)) else { return };
|
||||
let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0);
|
||||
// Like the DOCX reader: a page is at least half an inch; at most 22 inches (Word's limit), so a hostile
|
||||
// `1e30pt` can't reach layout.
|
||||
let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(1584.0));
|
||||
if let (Some(w), Some(h)) = (page("fo:page-width"), page("fo:page-height")) {
|
||||
sec.page_w = w;
|
||||
sec.page_h = h;
|
||||
}
|
||||
sec.landscape = match get(&a, "style:print-orientation") {
|
||||
Some("landscape") => true,
|
||||
Some("portrait") => false,
|
||||
_ => sec.page_w > sec.page_h,
|
||||
};
|
||||
for (k, slot) in [
|
||||
("fo:margin-top", &mut sec.margin_top),
|
||||
("fo:margin-bottom", &mut sec.margin_bottom),
|
||||
("fo:margin-left", &mut sec.margin_left),
|
||||
("fo:margin-right", &mut sec.margin_right),
|
||||
] {
|
||||
if let Some(v) = len(k) {
|
||||
// No margin is larger than the page's longer side (as `w:pgMar` in the DOCX reader).
|
||||
*slot = v.min(sec.page_w.max(sec.page_h));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1467,6 +1521,67 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_orientation_and_margins_round_trip() {
|
||||
// #810: the page setup written on export is read back on import.
|
||||
let mut doc = Document::new();
|
||||
doc.last_section.page_w = 841.0;
|
||||
doc.last_section.page_h = 595.0;
|
||||
doc.last_section.landscape = true;
|
||||
doc.last_section.margin_left = 36.0;
|
||||
doc.last_section.margin_top = 54.0;
|
||||
let back = import(&export(&doc).unwrap()).unwrap();
|
||||
let s = &back.last_section;
|
||||
assert!(s.landscape);
|
||||
assert_eq!((s.page_w, s.page_h), (841.0, 595.0));
|
||||
assert_eq!((s.margin_left, s.margin_top), (36.0, 54.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_follows_the_first_master_page() {
|
||||
let xml = br#"<office:document-styles><office:automatic-styles>
|
||||
<style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout>
|
||||
<style:page-layout style:name="pm2">
|
||||
<style:page-layout-properties fo:page-width="10in" fo:page-height="5in" fo:margin-left="1in" style:print-orientation="landscape"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles><office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm2"/>
|
||||
<style:master-page style:name="Other" style:page-layout-name="pm1"/>
|
||||
</office:master-styles></office:document-styles>"#;
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(xml, &mut sec);
|
||||
assert!(sec.landscape);
|
||||
assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_page_layout_values_are_clamped() {
|
||||
let styles = |w: &str, h: &str, m: &str| {
|
||||
format!(
|
||||
r#"<office:document-styles><office:automatic-styles><style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="{w}" fo:page-height="{h}" fo:margin-top="{m}"
|
||||
fo:margin-bottom="{m}" fo:margin-left="{m}" fo:margin-right="{m}"/></style:page-layout>
|
||||
</office:automatic-styles><office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles>
|
||||
</office:document-styles>"#
|
||||
)
|
||||
};
|
||||
// Huge values: the page is capped at 22 inches and margins at the page's longer side.
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(styles("1e30pt", "1e30in", "1e30pt").as_bytes(), &mut sec);
|
||||
assert_eq!((sec.page_w, sec.page_h), (1584.0, 1584.0));
|
||||
assert_eq!((sec.margin_top, sec.margin_left), (1584.0, 1584.0));
|
||||
// Negative, zero, tiny and non-numeric values leave the defaults alone.
|
||||
let def = wordcraft_doc::SectionProps::default();
|
||||
for (w, h, m) in [("-10in", "-5in", "-1in"), ("0pt", "0pt", "NaNpt"), ("1pt", "2pt", "infpt"), ("NaN", "inf", "x")] {
|
||||
let mut sec = wordcraft_doc::SectionProps::default();
|
||||
apply_page_layout(styles(w, h, m).as_bytes(), &mut sec);
|
||||
assert_eq!((sec.page_w, sec.page_h), (def.page_w, def.page_h), "{w} {h}");
|
||||
assert_eq!((sec.margin_top, sec.margin_right), (def.margin_top, def.margin_right), "{m}");
|
||||
assert!(sec.page_w.is_finite() && sec.margin_left.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_style_background_is_shading() {
|
||||
// #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading.
|
||||
|
||||
@@ -1003,10 +1003,8 @@ impl Reader {
|
||||
self.list_cur.clear();
|
||||
self.list_cur_id = 0;
|
||||
}
|
||||
"listlevel" => {
|
||||
if self.list_cur.len() < 9 {
|
||||
self.list_cur.push(true);
|
||||
}
|
||||
"listlevel" if self.list_cur.len() < 9 => {
|
||||
self.list_cur.push(true);
|
||||
}
|
||||
"levelnfc" | "levelnfcn" => {
|
||||
if let Some(l) = self.list_cur.last_mut() {
|
||||
|
||||
+27
-4
@@ -8,6 +8,9 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
|
||||
/// Points per inch.
|
||||
pub const PT_PER_IN: f32 = 72.0;
|
||||
/// Twips (1/20 pt) per point.
|
||||
@@ -80,18 +83,27 @@ impl Unit {
|
||||
}
|
||||
/// Format `pt` in this unit the way Word's fields show it (`1"`, `2.54 cm`, `12 pt`).
|
||||
pub fn format(self, pt: f32) -> String {
|
||||
self.format_with(pt, '.')
|
||||
}
|
||||
/// [`Unit::format`] with the region's decimal separator (`2,54 cm` with `,`).
|
||||
pub fn format_with(self, pt: f32, decimal: char) -> String {
|
||||
format!("{}{}", self.number(pt, decimal), self.suffix())
|
||||
}
|
||||
/// `pt` as a number in this unit, at most two decimals, without the unit (`2,54` with `,`).
|
||||
pub fn number(self, pt: f32, decimal: char) -> String {
|
||||
let v = finite(pt) / self.pt_per_unit();
|
||||
let s = format!("{v:.2}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
|
||||
let s = if s == "-0" { "0" } else { s };
|
||||
format!("{s}{}", self.suffix())
|
||||
s.replace('.', &decimal.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a measurement typed by a user: `1"`, `1 in`, `2.5cm`, `12pt`, `3 pi`, `10 mm`, `1.5 li`
|
||||
/// (lines are 12 pt). A bare number is in `default`. Returns points.
|
||||
/// (lines are 12 pt). A bare number is in `default`. Returns points. Either decimal separator
|
||||
/// works (`2,5 cm` and `2.5 cm`), whatever the region.
|
||||
pub fn parse_measure(s: &str, default: Unit) -> Option<f32> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
let t = s.trim().to_ascii_lowercase().replace('\u{2212}', "-");
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -305,6 +317,13 @@ mod tests {
|
||||
assert_eq!(parse_measure("abc", Unit::Inches), None);
|
||||
assert_eq!(parse_measure("", Unit::Inches), None);
|
||||
assert_eq!(parse_measure("1 furlong", Unit::Inches), None);
|
||||
// Both decimal separators, whatever the region (#838).
|
||||
let comma = parse_measure("2,54 cm", Unit::Inches).unwrap();
|
||||
assert!((comma - 72.0).abs() < 0.01);
|
||||
assert_eq!(parse_measure("1,5", Unit::Inches), Some(108.0));
|
||||
assert_eq!(parse_measure("1.5\"", Unit::Centimeters), Some(108.0));
|
||||
assert_eq!(parse_measure("\u{2212}1 in", Unit::Points), Some(-72.0));
|
||||
assert_eq!(parse_measure("1,2,3", Unit::Inches), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,6 +332,10 @@ mod tests {
|
||||
assert_eq!(Unit::Inches.format(90.0), "1.25\"");
|
||||
assert_eq!(Unit::Points.format(12.0), "12 pt");
|
||||
assert_eq!(Unit::Centimeters.format(72.0), "2.54 cm");
|
||||
assert_eq!(Unit::Centimeters.format_with(72.0, ','), "2,54 cm");
|
||||
assert_eq!(Unit::Inches.format_with(108.0, ','), "1,5\"");
|
||||
assert_eq!(Unit::Points.format_with(720.0, ','), "720 pt");
|
||||
assert_eq!(Unit::Inches.format_with(f32::NAN, ','), "0\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! The conventions of the user's region that new documents and the measurement fields follow
|
||||
//! (#838): the paper a new blank document starts on, the unit lengths are shown in, and the
|
||||
//! decimal separator. Word takes them from the system region, not from the interface language
|
||||
//! (an English interface in Germany still gets A4 and centimetres).
|
||||
//!
|
||||
//! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License):
|
||||
//! `paperSize` US-Letter and `measurementSystem` US. The decimal separators follow CLDR's number
|
||||
//! symbols for the common locales. The front end detects the region (it knows the platform); the
|
||||
//! engine only sees this plain value.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Unit;
|
||||
|
||||
/// Territories whose default paper is US Letter (CLDR `paperSize type="US-Letter"`).
|
||||
const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", "PH", "PR", "SV", "US", "VE"];
|
||||
|
||||
/// Territories that measure in inches (CLDR `measurementSystem type="US"`).
|
||||
const INCHES: &[&str] = &["LR", "MM", "US"];
|
||||
|
||||
/// Languages whose numbers use a decimal comma in most of their territories.
|
||||
const COMMA_LANGUAGES: &[&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", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk", "uz", "vi",
|
||||
];
|
||||
|
||||
/// Territories where those languages use a decimal point after all.
|
||||
const POINT_EXCEPTIONS: &[(&str, &[&str])] =
|
||||
&[("de", &["CH", "LI"]), ("it", &["CH"]), ("es", &["CU", "DO", "GT", "HN", "MX", "NI", "PA", "PE", "PR", "SV", "US", "419"])];
|
||||
|
||||
/// Territories where English (and other decimal-point languages) is written with a decimal
|
||||
/// comma: most of continental Europe, South Africa and much of South America.
|
||||
const COMMA_TERRITORIES: &[&str] = &[
|
||||
"AR", "AT", "BE", "BG", "BO", "BR", "CL", "CO", "CZ", "DE", "DK", "EC", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "ID", "IS", "IT", "LT", "LU",
|
||||
"LV", "NL", "NO", "PL", "PT", "PY", "RO", "RS", "RU", "SE", "SI", "SK", "TR", "UA", "UY", "VE", "VN", "ZA",
|
||||
];
|
||||
|
||||
/// A region: a language and a territory, read from a locale tag like `pl-PL`, `de_AT.UTF-8` or
|
||||
/// `en-US`. The default is `en-US` (Letter, inches, decimal point).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// Lower-case language subtag (`pl`); empty when the tag has none.
|
||||
pub language: String,
|
||||
/// Upper-case territory (`PL`, or a UN M.49 number like `419`); empty when the tag has none.
|
||||
pub territory: String,
|
||||
/// US Letter or not, when a separate paper locale says so (Unix `LC_PAPER`); otherwise the
|
||||
/// territory decides.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub letter: Option<bool>,
|
||||
/// Inches or not, when a separate measurement locale says so (Unix `LC_MEASUREMENT`);
|
||||
/// otherwise the territory decides.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inches: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for Region {
|
||||
fn default() -> Self {
|
||||
Region { language: "en".into(), territory: "US".into(), letter: None, inches: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The region a BCP 47 or POSIX locale tag names (`pl-PL`, `pl_PL.UTF-8@euro`,
|
||||
/// `zh-Hans-CN`, `en-US-u-ms-metric`). An empty or unreadable tag, `C` and `POSIX` are
|
||||
/// `en-US`; a bare `en` is the United States, any other bare language has no territory.
|
||||
pub fn from_tag(tag: &str) -> Region {
|
||||
// Locale tags are short; anything long is junk and only its start is read.
|
||||
let tag: String = tag.chars().take(64).collect();
|
||||
let tag = tag.split(['.', '@']).next().unwrap_or_default().trim();
|
||||
if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") {
|
||||
return Region::default();
|
||||
}
|
||||
let mut parts = tag.split(['-', '_']);
|
||||
let language = parts.next().unwrap_or_default();
|
||||
if !(2..=3).contains(&language.len()) || !language.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Region::default();
|
||||
}
|
||||
let language = language.to_ascii_lowercase();
|
||||
let mut territory = String::new();
|
||||
for p in parts {
|
||||
// A singleton starts the extensions (`-u-…`, `-x-…`): no territory after it.
|
||||
if p.len() == 1 {
|
||||
break;
|
||||
}
|
||||
let alpha = p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic());
|
||||
let digits = p.len() == 3 && p.chars().all(|c| c.is_ascii_digit());
|
||||
if alpha || digits {
|
||||
territory = p.to_ascii_uppercase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if territory.is_empty() && language == "en" {
|
||||
territory = "US".into();
|
||||
}
|
||||
Region { language, territory, letter: None, inches: None }
|
||||
}
|
||||
|
||||
/// This region with another territory (from a platform setting that names only the
|
||||
/// territory, like Windows' home location). A value that isn't a territory code is ignored.
|
||||
pub fn with_territory(mut self, territory: &str) -> Region {
|
||||
let t = territory.trim();
|
||||
let ok = (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()));
|
||||
if ok {
|
||||
self.territory = t.to_ascii_uppercase();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// This region with the paper and the measurement system taken from their own locale tags,
|
||||
/// when the platform sets them apart (Unix `LC_PAPER`, `LC_MEASUREMENT`): `LANG=pl_PL.UTF-8`
|
||||
/// with `LC_PAPER=en_US.UTF-8` writes `2,54 cm` on Letter.
|
||||
pub fn with_paper_and_measurement(mut self, paper: Option<&str>, measurement: Option<&str>) -> Region {
|
||||
if let Some(t) = paper {
|
||||
self.letter = Some(Region::from_tag(t).uses_letter());
|
||||
}
|
||||
if let Some(t) = measurement {
|
||||
self.inches = Some(Region::from_tag(t).units() == Unit::Inches);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Does a new document start on US Letter here (otherwise A4)?
|
||||
pub fn uses_letter(&self) -> bool {
|
||||
self.letter.unwrap_or_else(|| LETTER.contains(&self.territory.as_str()))
|
||||
}
|
||||
|
||||
/// The default paper's name and portrait size in points: Letter (612 × 792) or A4.
|
||||
pub fn paper(&self) -> (&'static str, f32, f32) {
|
||||
if self.uses_letter() { ("Letter", 612.0, 792.0) } else { ("A4", 595.28, 841.89) }
|
||||
}
|
||||
|
||||
/// The unit lengths are shown in by default: inches in the US, centimetres elsewhere.
|
||||
pub fn units(&self) -> Unit {
|
||||
if self.inches.unwrap_or_else(|| INCHES.contains(&self.territory.as_str())) { Unit::Inches } else { Unit::Centimeters }
|
||||
}
|
||||
|
||||
/// The decimal separator numbers are written with here (`,` or `.`).
|
||||
pub fn decimal(&self) -> char {
|
||||
let lang = self.language.as_str();
|
||||
let t = self.territory.as_str();
|
||||
if COMMA_LANGUAGES.contains(&lang) {
|
||||
let point = POINT_EXCEPTIONS.iter().any(|(l, ts)| *l == lang && ts.contains(&t));
|
||||
return if point { '.' } else { ',' };
|
||||
}
|
||||
if lang == "en" && COMMA_TERRITORIES.contains(&t) { ',' } else { '.' }
|
||||
}
|
||||
|
||||
/// `pt` in `unit` written the way this region writes numbers (`2,54 cm` in Poland).
|
||||
pub fn format(&self, unit: Unit, pt: f32) -> String {
|
||||
unit.format_with(pt, self.decimal())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_locale_tags() {
|
||||
assert_eq!(Region::from_tag("pl-PL"), Region { language: "pl".into(), territory: "PL".into(), ..Region::default() });
|
||||
assert_eq!(Region::from_tag("de_AT.UTF-8@euro").territory, "AT");
|
||||
assert_eq!(Region::from_tag("zh-Hans-CN").territory, "CN");
|
||||
assert_eq!(Region::from_tag("es-419").territory, "419");
|
||||
assert_eq!(Region::from_tag("en-u-ms-metric").territory, "US", "bare English is the US");
|
||||
assert_eq!(Region::from_tag("fr").territory, "");
|
||||
for junk in ["", "C", "POSIX", "1234", "x", "--", "ünïcode-ÄÄ", &"a".repeat(10_000)] {
|
||||
assert_eq!(Region::from_tag(junk), Region::default(), "{junk:?}");
|
||||
}
|
||||
assert_eq!(Region::from_tag("en-US").with_territory("de").territory, "DE");
|
||||
assert_eq!(Region::from_tag("en-US").with_territory("Germany").territory, "US");
|
||||
}
|
||||
|
||||
/// Paper and units follow the territory, not the language (#838).
|
||||
#[test]
|
||||
fn paper_and_units_follow_the_territory() {
|
||||
let pl = Region::from_tag("pl-PL");
|
||||
assert_eq!((pl.paper().0, pl.units(), pl.decimal()), ("A4", Unit::Centimeters, ','));
|
||||
let us = Region::from_tag("en-US");
|
||||
assert_eq!((us.paper().0, us.units(), us.decimal()), ("Letter", Unit::Inches, '.'));
|
||||
let ca = Region::from_tag("fr-CA");
|
||||
assert_eq!((ca.paper().0, ca.units(), ca.decimal()), ("Letter", Unit::Centimeters, ','));
|
||||
let en_de = Region::from_tag("en-DE");
|
||||
assert_eq!((en_de.paper().0, en_de.units(), en_de.decimal()), ("A4", Unit::Centimeters, ','));
|
||||
let gb = Region::from_tag("en-GB");
|
||||
assert_eq!((gb.paper().0, gb.units(), gb.decimal()), ("A4", Unit::Centimeters, '.'));
|
||||
assert_eq!(Region::from_tag("de-CH").decimal(), '.');
|
||||
assert_eq!(Region::from_tag("es-MX").paper().0, "Letter");
|
||||
assert_eq!(Region::from_tag("ja-JP").paper().0, "A4");
|
||||
assert_eq!(pl.format(Unit::Centimeters, 72.0), "2,54 cm");
|
||||
// Unix sets paper and measurement apart from the number locale.
|
||||
let mixed = Region::from_tag("pl_PL.UTF-8").with_paper_and_measurement(Some("en_US.UTF-8"), None);
|
||||
assert_eq!((mixed.paper().0, mixed.units(), mixed.decimal()), ("Letter", Unit::Centimeters, ','));
|
||||
let mixed = Region::from_tag("en_US.UTF-8").with_paper_and_measurement(Some("de_DE.UTF-8"), Some("de_DE.UTF-8"));
|
||||
assert_eq!((mixed.paper().0, mixed.units(), mixed.decimal()), ("A4", Unit::Centimeters, '.'));
|
||||
assert_eq!(Region::from_tag("pl-PL").with_paper_and_measurement(Some(""), Some("junk")).paper().0, "Letter", "junk is en-US");
|
||||
assert_eq!(us.format(Unit::Inches, 90.0), "1.25\"");
|
||||
}
|
||||
}
|
||||
@@ -1487,7 +1487,7 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f
|
||||
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0));
|
||||
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
|
||||
// The room is the rotated bounds of the (possibly scaled-down) frame: undo the rotation.
|
||||
// The room is the rotated bounds of the frame: undo the rotation.
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
|
||||
let (bw, _) = float.spin().extent(w, h);
|
||||
let k = if bw > 0.0 { room.w / bw } else { 1.0 };
|
||||
|
||||
+375
-39
@@ -48,15 +48,103 @@ pub enum ViewMode {
|
||||
Web,
|
||||
/// Draft: like web, page breaks shown as rules.
|
||||
Draft,
|
||||
/// Reading: reflow into viewport-height narrow pages, navigated horizontally.
|
||||
Read,
|
||||
/// Outline: like draft, the body as an outline of its headings ([`outline`]).
|
||||
Outline,
|
||||
}
|
||||
|
||||
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
|
||||
///
|
||||
/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and
|
||||
/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9,
|
||||
/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits
|
||||
/// are substituted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NumeralMode {
|
||||
/// Western digits everywhere (`0123456789`); the default, today's behaviour.
|
||||
#[default]
|
||||
Arabic,
|
||||
/// Eastern Arabic-Indic digits everywhere (`٠١٢٣٤٥٦٧٨٩`).
|
||||
Hindi,
|
||||
/// Hindi digits in and around right-to-left text, Western digits elsewhere (numbers take
|
||||
/// an even bidi level, so the surrounding text decides).
|
||||
Context,
|
||||
/// Hindi digits when the system locale is an Arabic-script language, else Western digits.
|
||||
/// Call [`NumeralMode::resolve`] before laying out.
|
||||
System,
|
||||
}
|
||||
|
||||
impl NumeralMode {
|
||||
/// Short UI label (Word's option names).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NumeralMode::Arabic => "Arabic",
|
||||
NumeralMode::Hindi => "Hindi",
|
||||
NumeralMode::Context => "Context",
|
||||
NumeralMode::System => "System",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a command value (`"arabic" | "hindi" | "context" | "system"`, any case).
|
||||
pub fn parse(s: &str) -> Option<NumeralMode> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"arabic" => Some(NumeralMode::Arabic),
|
||||
"hindi" => Some(NumeralMode::Hindi),
|
||||
"context" => Some(NumeralMode::Context),
|
||||
"system" => Some(NumeralMode::System),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `System` against the system locale (a BCP 47 or POSIX tag such as `ar-EG` or
|
||||
/// `fa_IR.UTF-8`, found by the shell: layout never reads the environment). Hindi digits for
|
||||
/// Arabic-script languages, else Western; unknown locale = Western. Other modes pass through.
|
||||
pub fn resolve(self, system_locale: Option<&str>) -> NumeralMode {
|
||||
if self != NumeralMode::System {
|
||||
return self;
|
||||
}
|
||||
if system_locale.is_some_and(hindi_locale) { NumeralMode::Hindi } else { NumeralMode::Arabic }
|
||||
}
|
||||
|
||||
/// The displayed form of `c` in a run shaped with direction `rtl`.
|
||||
///
|
||||
/// One character maps to exactly one character, so shaped text stays codepoint-aligned
|
||||
/// with the document text (only byte offsets shift). A `System` mode that was not
|
||||
/// resolved falls back to Western digits.
|
||||
pub fn map(self, c: char, rtl: bool) -> char {
|
||||
let hindi = match self {
|
||||
NumeralMode::Arabic => false,
|
||||
NumeralMode::Hindi => true,
|
||||
NumeralMode::Context => rtl,
|
||||
NumeralMode::System => false,
|
||||
};
|
||||
if hindi {
|
||||
if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c }
|
||||
} else if ('\u{660}'..='\u{669}').contains(&c) {
|
||||
(b'0' + (c as u32 - 0x660) as u8) as char
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does locale `tag` use Hindi digits (Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish)?
|
||||
fn hindi_locale(tag: &str) -> bool {
|
||||
let lang = tag.split(['_', '-', '.', '@']).next().unwrap_or_default().to_ascii_lowercase();
|
||||
matches!(lang.as_str(), "ar" | "fa" | "ur" | "ps" | "sd" | "ku" | "ckb")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayoutOptions {
|
||||
pub view: ViewMode,
|
||||
/// Width of the window in Web/Draft views, points.
|
||||
pub web_width: f32,
|
||||
/// Page height in Reading Mode (document points).
|
||||
pub read_height: f32,
|
||||
/// Transient read-only line-spacing override; ignored by print/export views.
|
||||
pub read_line_spacing: Option<f32>,
|
||||
pub show_hidden: bool,
|
||||
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
|
||||
pub hide_deleted: bool,
|
||||
@@ -66,6 +154,9 @@ pub struct LayoutOptions {
|
||||
pub deletions_in_balloons: bool,
|
||||
/// Check spelling as you type (squiggles).
|
||||
pub proofing: bool,
|
||||
/// How digits look on screen (display only; the text is untouched). Callers resolve
|
||||
/// `System` ([`NumeralMode::resolve`]); an unresolved `System` shows Western digits.
|
||||
pub numeral: NumeralMode,
|
||||
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
|
||||
/// and show the comments there in balloons joined to their anchors, as Word prints them.
|
||||
/// The editor draws its own interactive balloons and leaves this off.
|
||||
@@ -360,7 +451,9 @@ struct Key {
|
||||
notes: u64,
|
||||
excl: u64,
|
||||
eq: u32,
|
||||
read_spacing: Option<u32>,
|
||||
grid: (u32, u32, u32, bool),
|
||||
fit: bool,
|
||||
hide_deleted: bool,
|
||||
}
|
||||
|
||||
@@ -409,6 +502,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
opts.hide_deleted,
|
||||
opts.deletions_in_balloons,
|
||||
opts.proofing,
|
||||
opts.numeral,
|
||||
opts.field_codes,
|
||||
opts.proof,
|
||||
wordcraft_proof::dictionary_generation(),
|
||||
@@ -506,6 +600,9 @@ struct Ctx<'a> {
|
||||
uses_totals: bool,
|
||||
/// Whether to note `uses_totals` now (off while laying out headers and footers).
|
||||
track_totals: bool,
|
||||
/// Body blocks that are empty paragraphs carrying only a section break before a continuous
|
||||
/// section, in order: they take no room (see [`place_para`]).
|
||||
quiet_marks: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
@@ -549,9 +646,12 @@ impl Ctx<'_> {
|
||||
hide_deleted: false,
|
||||
table: None,
|
||||
proofing: None,
|
||||
numeral: self.opts.numeral,
|
||||
exclusions: &[],
|
||||
eq_number: 0,
|
||||
read_line_spacing: None,
|
||||
grid: para::Grid::default(),
|
||||
fit_objects: false,
|
||||
};
|
||||
let pl = Arc::new(para::layout_para(&p, &env));
|
||||
self.numbers.insert(n, pl.clone());
|
||||
@@ -599,7 +699,9 @@ impl Ctx<'_> {
|
||||
notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 },
|
||||
excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) },
|
||||
eq: if eq_here > 0 { eq_number } else { 0 },
|
||||
read_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing.map(f32::to_bits) } else { None },
|
||||
grid: self.grid.key(),
|
||||
fit: self.boxes.inside(),
|
||||
hide_deleted: self.hide_deleted,
|
||||
};
|
||||
self.cache.used.insert(key.clone());
|
||||
@@ -618,9 +720,12 @@ impl Ctx<'_> {
|
||||
hide_deleted: self.hide_deleted,
|
||||
table,
|
||||
proofing: self.opts.proofing.then_some(self.opts.proof),
|
||||
numeral: self.opts.numeral,
|
||||
exclusions,
|
||||
eq_number,
|
||||
read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None },
|
||||
grid: self.grid,
|
||||
fit_objects: self.boxes.inside(),
|
||||
};
|
||||
let pl = Arc::new(para::layout_para(p, &env));
|
||||
self.cache.paras.insert(key, pl.clone());
|
||||
@@ -770,19 +875,60 @@ fn layout_box(
|
||||
let mut float_tables: Vec<Rect> = Vec::new();
|
||||
let mut y = 0.0f32;
|
||||
let mut prev_after = 0.0f32;
|
||||
let mut prev_after_auto = false;
|
||||
let mut prev_listed = false;
|
||||
let mut prev_style: Option<(String, bool)> = None;
|
||||
let mut last_top = 0.0f32;
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
last_top = y;
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let label = ctx.next_label(p);
|
||||
let listed = label.is_some();
|
||||
let mut pl = ctx.para_labelled(p, width, table, &[], label.clone());
|
||||
// A text frame (a footer's page number in a frame, say) is placed apart, like a
|
||||
// floating object, and the text after it wraps around it; it takes no room in
|
||||
// the flow.
|
||||
if let Some(fr) = pl.rp.frame.clone() {
|
||||
let fw = fr.w.unwrap_or_else(|| natural_width(&pl)).clamp(1.0, 1584.0);
|
||||
let pf = if (fw - width).abs() > 0.01 { ctx.para_labelled(p, fw, table, &[], label) } else { pl };
|
||||
let h = match fr.h {
|
||||
Some(h) if fr.h_exact => h,
|
||||
Some(h) => h.max(pf.height),
|
||||
None => pf.height,
|
||||
};
|
||||
let float = frame_float(&fr);
|
||||
let r = float_rect(frame, (0.0, width), y, fw, h, &float);
|
||||
push_para(&mut items, story, &path, &pf, 0, pf.lines.len(), r.x, r.y, fw);
|
||||
excl.extend(wrap_area(r, &float, None));
|
||||
float_tables.push(r);
|
||||
continue;
|
||||
}
|
||||
let ctxl = pl.rp.contextual_spacing;
|
||||
let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl));
|
||||
let before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
||||
let mut before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
||||
if same && ctxl {
|
||||
y -= prev_after;
|
||||
prev_after = 0.0;
|
||||
}
|
||||
// Automatic spacing: none between two list items (as in an HTML list).
|
||||
if i > 0 && prev_listed && listed {
|
||||
if prev_after_auto {
|
||||
y -= prev_after;
|
||||
prev_after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of a cell or box, and it collapses with the
|
||||
// space after the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && i == 0 {
|
||||
before = 0.0;
|
||||
} else if i > 0 && (pl.rp.before_auto || prev_after_auto) {
|
||||
before = (before - prev_after).max(0.0);
|
||||
}
|
||||
// Word: space between paragraphs is before + after (no collapsing).
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
@@ -815,8 +961,12 @@ fn layout_box(
|
||||
items.splice(at..at, back);
|
||||
items.extend(front);
|
||||
}
|
||||
y += pl.height + pl.rp.space_after;
|
||||
prev_after = if same && ctxl { 0.0 } else { pl.rp.space_after };
|
||||
// Automatic space after: none after the last paragraph of a cell or box.
|
||||
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
|
||||
y += pl.height + after;
|
||||
prev_after = if same && ctxl { 0.0 } else { after };
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
prev_listed = listed;
|
||||
prev_style = Some((pl.rp.style.clone(), ctxl));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
@@ -856,15 +1006,64 @@ fn layout_box(
|
||||
}
|
||||
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
|
||||
ctx.hide_deleted = outer_hide;
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom }
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0) }
|
||||
}
|
||||
|
||||
/// A laid-out box: items relative to its top-left, the height of its text flow, and where its
|
||||
/// floating tables end (a table cell grows to hold them).
|
||||
/// The width a text frame without one takes: its widest line, with the paragraph's indents.
|
||||
fn natural_width(pl: &ParaLayout) -> f32 {
|
||||
let widest = (0..pl.lines.len())
|
||||
.filter_map(|li| {
|
||||
let l = pl.lines.get(li)?;
|
||||
Some((pl.x_of(li, l.stop)? - pl.x_of(li, l.start)?).abs())
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
widest + pl.rp.indent_left.max(0.0) + pl.rp.indent_right.max(0.0)
|
||||
}
|
||||
|
||||
/// A text frame's placement as a floating object's (`w:framePr` → anchors, alignment, wrap).
|
||||
fn frame_float(fr: &wordcraft_doc::props::TextFrame) -> Float {
|
||||
let anchor = |a: &str, text: Anchor| match a {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
_ => text,
|
||||
};
|
||||
let align = |a: Option<&str>| match a {
|
||||
Some("left" | "top") => Some(FloatAlign::Start),
|
||||
Some("center") => Some(FloatAlign::Center),
|
||||
Some("right" | "bottom") => Some(FloatAlign::End),
|
||||
Some("inside") => Some(FloatAlign::Inside),
|
||||
Some("outside") => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
};
|
||||
Float {
|
||||
wrap: match fr.wrap.as_str() {
|
||||
"notBeside" => Wrap::TopAndBottom,
|
||||
"none" => Wrap::InFrontOfText,
|
||||
"tight" => Wrap::Tight,
|
||||
"through" => Wrap::Through,
|
||||
_ => Wrap::Square,
|
||||
},
|
||||
h_rel: anchor(&fr.h_anchor, Anchor::Column),
|
||||
v_rel: anchor(&fr.v_anchor, Anchor::Paragraph),
|
||||
x: fr.x,
|
||||
y: fr.y,
|
||||
h_align: align(fr.x_align.as_deref()),
|
||||
v_align: align(fr.y_align.as_deref()),
|
||||
dist: fr.h_space,
|
||||
dist_top: fr.v_space,
|
||||
dist_bottom: fr.v_space,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A laid-out box: items relative to its top-left, the height of its text flow, where its
|
||||
/// floating tables end (a table cell grows to hold them) and where its last block starts (the
|
||||
/// height without it).
|
||||
struct BoxLayout {
|
||||
items: Vec<Placed>,
|
||||
height: f32,
|
||||
floats_bottom: f32,
|
||||
last_top: f32,
|
||||
}
|
||||
|
||||
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
||||
@@ -924,8 +1123,11 @@ fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f3
|
||||
r
|
||||
}
|
||||
|
||||
/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
|
||||
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
|
||||
/// own come from its [`wordcraft_doc::TextBody`].
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_X: f32 = 7.2;
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_Y: f32 = 3.6;
|
||||
|
||||
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are
|
||||
@@ -1039,8 +1241,12 @@ struct PageBuilder<'a> {
|
||||
carry: Vec<NoteBox>,
|
||||
/// Page bottom before footnotes took space.
|
||||
orig_bottom: f32,
|
||||
/// The previous paragraph: (style, contextual spacing, space after) for contextual spacing.
|
||||
prev: Option<(String, bool, f32)>,
|
||||
/// The paragraph above: its style, contextual spacing, space after, whether that is automatic
|
||||
/// and whether it is a list item.
|
||||
prev: Option<(String, bool, f32, bool, bool)>,
|
||||
/// The previous paragraph ended with a page break (its mark stayed with the break): the next
|
||||
/// paragraph, first on the new page, gets no space before (Word).
|
||||
after_break_mark: bool,
|
||||
/// Wrap areas of floating objects on this page (see [`wrap_area`]).
|
||||
excl: Vec<WrapArea>,
|
||||
/// Wrap areas of the floating tables on this page, which others may not overlap.
|
||||
@@ -1581,9 +1787,18 @@ fn layout_pass(
|
||||
grid: para::Grid::default(),
|
||||
uses_totals: false,
|
||||
track_totals: true,
|
||||
quiet_marks: Vec::new(),
|
||||
};
|
||||
let sections = doc.sections();
|
||||
let web = opts.view != ViewMode::Print;
|
||||
let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline);
|
||||
let read = opts.view == ViewMode::Read;
|
||||
let reflow = web || read;
|
||||
ctx.quiet_marks = sections
|
||||
.windows(2)
|
||||
.filter(|w| w[1].1.start == SectionStart::Continuous)
|
||||
.map(|w| w[0].0)
|
||||
.filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()))
|
||||
.collect();
|
||||
let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default()));
|
||||
let default_sect = SectionProps::default();
|
||||
let mut web_sect;
|
||||
@@ -1591,14 +1806,21 @@ fn layout_pass(
|
||||
Some((_, s)) => *s,
|
||||
None => &default_sect,
|
||||
};
|
||||
let sect_ref = if web {
|
||||
let sect_ref = if reflow {
|
||||
web_sect = first_sect.clone();
|
||||
web_sect.page_w = opts.web_width.max(144.0);
|
||||
web_sect.page_w = if opts.web_width.is_finite() { opts.web_width.clamp(144.0, 20_000.0) } else { 300.0 };
|
||||
if read {
|
||||
web_sect.page_h = if opts.read_height.is_finite() { opts.read_height.clamp(144.0, 20_000.0) } else { 500.0 };
|
||||
web_sect.margin_bottom = 18.0;
|
||||
web_sect.page_borders = None;
|
||||
web_sect.line_numbers = None;
|
||||
}
|
||||
web_sect.margin_left = 18.0;
|
||||
web_sect.margin_right = 18.0;
|
||||
web_sect.margin_top = 18.0;
|
||||
web_sect.gutter = 0.0;
|
||||
web_sect.columns = Default::default();
|
||||
web_sect.valign = wordcraft_doc::props::VAlign::Top;
|
||||
&web_sect
|
||||
} else {
|
||||
first_sect
|
||||
@@ -1618,6 +1840,7 @@ fn layout_pass(
|
||||
carry: Vec::new(),
|
||||
orig_bottom: 0.0,
|
||||
prev: None,
|
||||
after_break_mark: false,
|
||||
excl: Vec::new(),
|
||||
float_tables: Vec::new(),
|
||||
line_no: 0,
|
||||
@@ -1660,10 +1883,10 @@ fn layout_pass(
|
||||
.collect();
|
||||
let mut prev_turn = TextDirection::Horizontal;
|
||||
for (si, (end, sect)) in sections.iter().enumerate() {
|
||||
let turn = if web { TextDirection::Horizontal } else { sect.text_direction };
|
||||
let turn = if reflow { TextDirection::Horizontal } else { sect.text_direction };
|
||||
let sect: &SectionProps = match turned.get(si) {
|
||||
Some(Some(t)) => t,
|
||||
_ if web => sect_ref,
|
||||
_ if reflow => sect_ref,
|
||||
_ => sect,
|
||||
};
|
||||
pb.sect = sect;
|
||||
@@ -1678,10 +1901,10 @@ fn layout_pass(
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
// Headers and footers left out are linked to the previous section's, and take its room.
|
||||
let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers));
|
||||
let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
|
||||
let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
|
||||
let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
|
||||
let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) };
|
||||
let body_top = if reflow || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
|
||||
let first_top = if reflow || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
|
||||
let foot_top = if reflow || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
|
||||
let first_foot = if reflow || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) };
|
||||
ctx.grid = para::Grid::of(doc, sect);
|
||||
let restart = sect.page_num_start;
|
||||
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
||||
@@ -1790,13 +2013,13 @@ fn layout_pass(
|
||||
p.h = bottom + 36.0;
|
||||
}
|
||||
ctx.grid = para::Grid::default();
|
||||
if !web && ctx.uses_totals && may_repeat {
|
||||
if !reflow && ctx.uses_totals && may_repeat {
|
||||
let found = Totals::of(&pages, sections.len());
|
||||
if found != *totals {
|
||||
return Pass::Again(found);
|
||||
}
|
||||
}
|
||||
if !web {
|
||||
if !reflow {
|
||||
ctx.track_totals = false;
|
||||
headers_footers(&mut ctx, &mut pages, §ions);
|
||||
if doc.settings.mirror_margins {
|
||||
@@ -2256,6 +2479,7 @@ fn anchor_floats(
|
||||
pb: &mut PageBuilder,
|
||||
p: &Paragraph,
|
||||
y0: f32,
|
||||
before: f32,
|
||||
excl_mark: usize,
|
||||
label: &Option<(String, Level)>,
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>) {
|
||||
@@ -2273,7 +2497,8 @@ fn anchor_floats(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
let r = float_rect(Some(frame), (col_x, width), y0, w, h, float);
|
||||
// Positions relative to the paragraph start above its space before (Word for Windows).
|
||||
let r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
|
||||
rects.insert(oi, r);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
if let Some(area) = wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())) {
|
||||
@@ -2401,7 +2626,25 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32
|
||||
notes
|
||||
}
|
||||
|
||||
/// Place a body paragraph. An empty paragraph that only carries a section break before a
|
||||
/// continuous section takes no room (Word for Windows: the text after it follows right on, and
|
||||
/// columns balanced above it don't count it).
|
||||
fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) {
|
||||
if !pb.web && ctx.quiet_marks.binary_search(&block).is_ok() {
|
||||
// Its (empty) line stays where the text ended, even at the bottom of a full column.
|
||||
let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col));
|
||||
pb.bottom = f32::MAX / 4.0;
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
pb.bottom = bottom;
|
||||
if (pb.pages.len(), pb.col) == at {
|
||||
(pb.y, pb.prev, pb.content_bottom) = (y, prev, content_bottom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
}
|
||||
|
||||
fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) {
|
||||
let width = pb.col_w();
|
||||
// Floating objects anchored here join the page's wrap areas before the text is laid out; if
|
||||
// the paragraph then starts somewhere else (another column or page, or contextual spacing
|
||||
@@ -2410,42 +2653,88 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
||||
let excl_mark = pb.excl.len();
|
||||
let guess = pb.y + space_before;
|
||||
let label = ctx.next_label(p);
|
||||
let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, excl_mark, &label);
|
||||
// The anchor paragraph's top the floats were placed from.
|
||||
let mut anchor_y = guess;
|
||||
let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, space_before, excl_mark, &label);
|
||||
// The anchor paragraph's top (above its space before) the floats were placed from.
|
||||
let mut anchor_y = pb.y;
|
||||
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
|
||||
let placed_at = spot(pb);
|
||||
if pl.rp.page_break_before && !pb.at_top() && !pb.web {
|
||||
let broke_before = pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
|
||||
let mut before = pl.rp.space_before;
|
||||
if let Some((style, ctxl, after)) = pb.prev.take()
|
||||
&& style == pl.rp.style
|
||||
let prev_para = pb.prev.clone();
|
||||
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
|
||||
// ended with a page break, nor (from Word 2013's layout, compatibility mode 15) for a page
|
||||
// break before; a section break or, in older modes, a page break before keeps it.
|
||||
let after_break_mark = std::mem::take(&mut pb.after_break_mark);
|
||||
if pb.at_top() && (after_break_mark || (broke_before && ctx.doc.settings.compat_mode >= 15)) {
|
||||
before = 0.0;
|
||||
} else if pb.at_top()
|
||||
&& let Some((_, _, after, _, _)) = &prev_para
|
||||
{
|
||||
// After a page break before or a section break the space between the paragraphs is
|
||||
// still counted as one: the previous paragraph's space after (left on its page) comes
|
||||
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
|
||||
before = (before - after.max(0.0)).max(0.0);
|
||||
}
|
||||
let mut prev = pb.prev.take();
|
||||
if let Some((style, ctxl, after, _, _)) = &mut prev
|
||||
&& *style == pl.rp.style
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if ctxl {
|
||||
pb.y -= after;
|
||||
if *ctxl {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
}
|
||||
if pl.rp.contextual_spacing {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
|
||||
// two items, of one list or two, at any level).
|
||||
if let Some((_, _, after, after_auto, true)) = &mut prev
|
||||
&& label.is_some()
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if *after_auto {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of the document, and it collapses with the space after
|
||||
// the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && block == 0 {
|
||||
before = 0.0;
|
||||
} else if let Some((_, _, after, after_auto, _)) = prev
|
||||
&& (pl.rp.before_auto || after_auto)
|
||||
&& !pb.at_top()
|
||||
{
|
||||
before = (before - after).max(0.0);
|
||||
}
|
||||
pb.y += before;
|
||||
// Keep lines together: if it doesn't fit but would on an empty column, move it.
|
||||
if pl.rp.keep_lines || pl.rp.keep_next {
|
||||
let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width) } else { 0.0 };
|
||||
let mut applied_before = before;
|
||||
// Keep lines together: if it doesn't fit but would on an empty column, move it. Keep with
|
||||
// next alone moves a paragraph only when it would fit here by itself (Word for Windows): one
|
||||
// that doesn't breaks as usual, its last lines going over with the next paragraph.
|
||||
if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + pl.height <= pb.bottom + 0.01) {
|
||||
let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0) } else { 0.0 };
|
||||
// (Against the whole column: balanced columns grow to hold it.)
|
||||
if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top {
|
||||
pb.advance(block, body_top);
|
||||
pb.y += pl.rp.space_before.min(0.0);
|
||||
applied_before = pl.rp.space_before.min(0.0);
|
||||
}
|
||||
}
|
||||
// A different top only matters when there is something to wrap around.
|
||||
if spot(pb) != placed_at || ((pb.y - guess).abs() > 0.01 && !pb.excl.is_empty()) {
|
||||
let y0 = pb.y;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label);
|
||||
anchor_y = y0;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, applied_before, excl_mark, &label);
|
||||
anchor_y = y0 - applied_before;
|
||||
}
|
||||
let mut n = pl.lines.len();
|
||||
let mut l0 = 0;
|
||||
@@ -2456,6 +2745,9 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
||||
let lead = if l0 == 0 { first.top.max(0.0) } else { 0.0 };
|
||||
let mut l1 = l0;
|
||||
let mut forced_break = None;
|
||||
// A page break followed only by the paragraph mark: the mark's (empty) line stays on this
|
||||
// page with the break, so nothing of the paragraph goes to the next page (Word).
|
||||
let mut mark_with_break = false;
|
||||
let mut new_notes: Vec<NoteBox> = Vec::new();
|
||||
let notes = para_notes(ctx.doc, p, &pl);
|
||||
while l1 < n {
|
||||
@@ -2509,6 +2801,10 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
||||
l1 += 1;
|
||||
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
|
||||
forced_break = Some(l.end);
|
||||
if l.end == LineEnd::PageBreak && l1 + 1 == n && pl.lines.get(l1).is_some_and(|m| m.start >= m.stop) {
|
||||
l1 += 1;
|
||||
mark_with_break = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if split {
|
||||
@@ -2534,7 +2830,7 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
||||
pb.advance(block, body_top);
|
||||
if l0 == 0 {
|
||||
let y0 = pb.y;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label);
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label);
|
||||
anchor_y = y0;
|
||||
n = pl.lines.len();
|
||||
}
|
||||
@@ -2619,18 +2915,46 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
||||
pb.content_bottom = pb.content_bottom.max(pb.y);
|
||||
}
|
||||
l0 = l1;
|
||||
// A paragraph that starts with a page break starts on the new page, its space before
|
||||
// there (Word).
|
||||
let starts_with_break = l0 == 1
|
||||
&& pl.lines.first().is_some_and(|l| {
|
||||
p.text.get(l.start..l.stop).is_some_and(|t| !t.is_empty() && t.chars().all(|c| c == wordcraft_doc::para::PAGE_BREAK))
|
||||
});
|
||||
match forced_break {
|
||||
Some(LineEnd::PageBreak) if mark_with_break => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.after_break_mark = true;
|
||||
}
|
||||
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.y += pl.rp.space_before.max(0.0);
|
||||
}
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => {
|
||||
pb.col_break_page = Some(pb.pages.len().saturating_sub(1));
|
||||
pb.advance(block, body_top)
|
||||
pb.advance(block, body_top);
|
||||
// Unequal columns: the text after the break is broken for its own column's width
|
||||
// (Word), not the width of the column it started in.
|
||||
if (pb.col_w() - width).abs() > 0.5
|
||||
&& let Some(resume) = pl.lines.get(l0).map(|l| l.start)
|
||||
{
|
||||
let y0 = pb.y;
|
||||
let (rects, relaid) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label);
|
||||
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
|
||||
(float_rects, pl) = (rects, relaid);
|
||||
anchor_y = y0;
|
||||
n = pl.lines.len();
|
||||
l0 = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if l0 < n => pb.advance(block, body_top),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pb.y += pl.rp.space_after;
|
||||
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after));
|
||||
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after, pl.rp.after_auto, label.is_some()));
|
||||
}
|
||||
|
||||
/// The outline of the body for an Outline view pass.
|
||||
@@ -2700,7 +3024,10 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo
|
||||
}
|
||||
|
||||
/// Height of the first line of the block after `block` (for keep-with-next).
|
||||
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
/// The room the paragraphs after `block` need on its page for keep with next: the next one's
|
||||
/// first line, or all of it and what it keeps with in turn when it is kept with the next too
|
||||
/// (a chain moves together in Word).
|
||||
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 {
|
||||
match ctx.doc.body.get(block + 1).map(|b| &**b) {
|
||||
Some(Block::Para(p)) => {
|
||||
// Don't advance list counters for a lookahead: lay out without the label.
|
||||
@@ -2715,13 +3042,20 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
|
||||
table: None,
|
||||
proofing: None,
|
||||
numeral: ctx.opts.numeral,
|
||||
exclusions: &[],
|
||||
eq_number: ctx.eq_count,
|
||||
read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None },
|
||||
grid: ctx.grid,
|
||||
fit_objects: false,
|
||||
};
|
||||
let _ = rp;
|
||||
let pl = para::layout_para(p, &env);
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
if pl.rp.keep_next && depth < 8 {
|
||||
pl.rp.space_before + pl.height + pl.rp.space_after + next_first_line(ctx, block + 1, width, depth + 1)
|
||||
} else {
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
Some(Block::Table(_)) => 24.0,
|
||||
None => 0.0,
|
||||
@@ -3038,6 +3372,8 @@ mod tests_complex;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_numeral;
|
||||
#[cfg(test)]
|
||||
mod tests_pages;
|
||||
#[cfg(test)]
|
||||
mod tests_textbox;
|
||||
|
||||
+142
-18
@@ -12,6 +12,7 @@ use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
|
||||
use crate::NumeralMode;
|
||||
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text};
|
||||
|
||||
/// A face at a size with the formatting needed to draw it.
|
||||
@@ -334,13 +335,19 @@ pub struct ParaEnv<'a> {
|
||||
pub table: Option<&'a CellText>,
|
||||
/// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles.
|
||||
pub proofing: Option<crate::ProofOptions>,
|
||||
/// How digits look on screen (an unresolved `System` shows Western digits).
|
||||
pub numeral: NumeralMode,
|
||||
/// Areas text must flow around (floating objects), relative to the paragraph: x from the
|
||||
/// column's left edge, y from the top of the first line.
|
||||
pub exclusions: &'a [Exclusion],
|
||||
/// Automatic equation numbers used before this paragraph.
|
||||
pub eq_number: u32,
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// The section's document grid (`Grid::default()`: none).
|
||||
pub grid: Grid,
|
||||
/// Shrink inline drawings wider than the line to fit it: in text boxes, which don't clip what
|
||||
/// runs past their sides. Elsewhere such a drawing keeps its size and the page edge clips it.
|
||||
pub fit_objects: bool,
|
||||
}
|
||||
|
||||
/// The section's document grid as layout applies it (`w:docGrid`, ECMA-376 §17.6.5).
|
||||
@@ -467,6 +474,64 @@ fn is_mark(c: char) -> bool {
|
||||
matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control()
|
||||
}
|
||||
|
||||
/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669).
|
||||
fn is_numeral_digit(c: char) -> bool {
|
||||
c.is_ascii_digit() || ('\u{660}'..='\u{669}').contains(&c)
|
||||
}
|
||||
|
||||
/// Characters skipped when looking for the text around a number: digits, spaces and the
|
||||
/// Arabic decimal/thousands separators (٫ ٬).
|
||||
fn is_number_inside(c: char) -> bool {
|
||||
is_numeral_digit(c) || ('\u{6f0}'..='\u{6f9}').contains(&c) || c == ' ' || c == '\t' || c == '\u{66b}' || c == '\u{66c}'
|
||||
}
|
||||
|
||||
/// Which digits of run `text` sit in right-to-left surroundings (Word › Numeral › Context), by
|
||||
/// byte offset. European numbers take an even bidi level, so a digit's own level says little;
|
||||
/// the nearest neighbour on either side that isn't part of the number (digits, spaces,
|
||||
/// separators) decides. One pass each way, so long runs of digits and spaces stay linear.
|
||||
/// Offsets past `levels` count as left to right.
|
||||
fn context_digit_dirs(text: &str, levels: &[u8], base: usize) -> Vec<bool> {
|
||||
let odd = |o: usize| levels.get(base.saturating_add(o)).is_some_and(|l| l % 2 == 1);
|
||||
let mut out = vec![false; text.len()];
|
||||
let mut left_rtl = false;
|
||||
for (o, c) in text.char_indices() {
|
||||
if !is_number_inside(c) {
|
||||
left_rtl = odd(o);
|
||||
} else if is_numeral_digit(c)
|
||||
&& let Some(v) = out.get_mut(o)
|
||||
{
|
||||
*v = odd(o) || left_rtl;
|
||||
}
|
||||
}
|
||||
let mut right_rtl = false;
|
||||
for (o, c) in text.char_indices().rev() {
|
||||
if !is_number_inside(c) {
|
||||
right_rtl = odd(o);
|
||||
} else if right_rtl
|
||||
&& is_numeral_digit(c)
|
||||
&& let Some(v) = out.get_mut(o)
|
||||
{
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a byte range of numeral-substituted display text back to the original text.
|
||||
///
|
||||
/// `map_starts[i]` is the display byte offset of the `i`th char and `orig_of[i]` its original
|
||||
/// byte offset (`orig_of` ends with the original length as a sentinel). Substitution is
|
||||
/// 1 char → 1 char, so both ends land on char boundaries; anything else falls back to the
|
||||
/// end of the original text instead of panicking or slicing mid-character.
|
||||
pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usize, ms: usize, me: usize) -> (usize, usize) {
|
||||
let n = map_starts.len();
|
||||
let i = map_starts.partition_point(|&b| b < ms).min(n);
|
||||
let j = map_starts.partition_point(|&b| b < me).min(n);
|
||||
let s = orig_of.get(i).copied().unwrap_or(orig_len);
|
||||
let e = orig_of.get(j).copied().unwrap_or(orig_len);
|
||||
(s.min(e), s.max(e))
|
||||
}
|
||||
|
||||
/// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the
|
||||
/// paragraph is left to right and has no right-to-left characters.
|
||||
pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec<u8> {
|
||||
@@ -517,6 +582,15 @@ fn east_asian(c: char) -> bool {
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
|
||||
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
|
||||
/// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European
|
||||
/// numbers take an even bidi level.
|
||||
fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char {
|
||||
let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl };
|
||||
self.env.numeral.map(c, dir)
|
||||
}
|
||||
|
||||
/// Style index for a resolved char in `face` (caps-scaled `size` override for small caps).
|
||||
fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u16 {
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
@@ -595,6 +669,7 @@ impl<'a> Builder<'a> {
|
||||
.filter(|r| !r.substituted)
|
||||
.map(|r| r.face);
|
||||
let use_levels = para_text && !self.levels.is_empty();
|
||||
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
|
||||
// One fallback face for the run's East Asian characters: the first face in fallback order
|
||||
// covering every character the primary font lacks, so the run draws in a single CJK face
|
||||
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
|
||||
@@ -616,27 +691,31 @@ impl<'a> Builder<'a> {
|
||||
let mut cur: Option<SegKey> = None;
|
||||
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
|
||||
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
|
||||
// fallback face are picked for the displayed char, so Hindi digits land in an
|
||||
// Arabic-capable face and Western digits in a Latin one.
|
||||
let dc = self.numeral_char(&context, i, c, rtl);
|
||||
let mark = is_mark(c);
|
||||
let complex = match cur {
|
||||
Some(k) if mark => k.complex,
|
||||
_ => rc_cs.is_some() && rc.uses_complex(c),
|
||||
_ => rc_cs.is_some() && rc.uses_complex(dc),
|
||||
};
|
||||
let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };
|
||||
let face = match cur {
|
||||
Some(k) if mark => k.face,
|
||||
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
|
||||
_ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
|
||||
_ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
|
||||
// Stay in the fallback face the word started in while it covers the letters.
|
||||
Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f),
|
||||
Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
|
||||
// The run's unified East Asian face, when one face covers the whole run.
|
||||
_ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
|
||||
_ => {
|
||||
let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
|
||||
wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
|
||||
wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
};
|
||||
let small = rc.small_caps && !rc.caps && c.is_lowercase();
|
||||
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
|
||||
let k = SegKey { face, small, complex, rtl };
|
||||
match cur {
|
||||
Some(p) if p.same(&k) => {}
|
||||
@@ -651,6 +730,7 @@ impl<'a> Builder<'a> {
|
||||
if let Some(p) = cur {
|
||||
segs.push((seg_start, text.len(), p));
|
||||
}
|
||||
let no_kern = [wordcraft_fonts::feature_value(b"kern", 0)];
|
||||
for (a, b, key) in segs {
|
||||
let Some(sub) = text.get(a..b) else { continue };
|
||||
let rc = match (&rc_cs, key.complex) {
|
||||
@@ -662,21 +742,40 @@ impl<'a> Builder<'a> {
|
||||
let si = self.style(rc, key.face, small);
|
||||
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
|
||||
let upper = rc.caps || small;
|
||||
// Displayed text: numeral substitution is 1 char → 1 char, so codepoints stay
|
||||
// aligned with the document and only byte offsets shift; `orig_of` maps each
|
||||
// displayed char back to its original byte offset (+ a `sub.len()` sentinel).
|
||||
let substituted = sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
|
||||
let mut mapped = String::new();
|
||||
let mut map_starts: Vec<usize> = Vec::new();
|
||||
let mut orig_of: Vec<usize> = Vec::new();
|
||||
if substituted {
|
||||
mapped = String::with_capacity(sub.len());
|
||||
for (i, c) in sub.char_indices() {
|
||||
map_starts.push(mapped.len());
|
||||
orig_of.push(i);
|
||||
mapped.push(self.numeral_char(&context, a + i, c, key.rtl));
|
||||
}
|
||||
orig_of.push(sub.len());
|
||||
}
|
||||
let shape_text = if substituted { mapped.as_str() } else { sub };
|
||||
let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||||
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
|
||||
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run(&st.face, sub, &[], map, key.rtl)
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
} else {
|
||||
wordcraft_fonts::shape(&st.face, sub, &[], map)
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
|
||||
// Complex-script syllables the graphemes split (Myanmar vowel signs, stacked
|
||||
// consonants) stay one cluster: the caret never stops inside them.
|
||||
let bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(sub);
|
||||
let bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(shape_text);
|
||||
let mut gi = 0usize;
|
||||
for (bi, &gs) in bounds.iter().enumerate() {
|
||||
let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len());
|
||||
let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len());
|
||||
let g0 = self.glyphs.len() as u32;
|
||||
let mut adv = 0.0f32;
|
||||
while let Some(g) = shaped.get(gi) {
|
||||
@@ -688,7 +787,7 @@ impl<'a> Builder<'a> {
|
||||
gi += 1;
|
||||
}
|
||||
let g1 = self.glyphs.len() as u32;
|
||||
let s = sub.get(gs..ge).unwrap_or("");
|
||||
let s = shape_text.get(gs..ge).unwrap_or("");
|
||||
let ch = s.chars().next().unwrap_or(' ');
|
||||
let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' {
|
||||
ClKind::Space
|
||||
@@ -698,9 +797,11 @@ impl<'a> Builder<'a> {
|
||||
ClKind::Text
|
||||
});
|
||||
let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing };
|
||||
// Clusters index the document text, never the displayed text.
|
||||
let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) };
|
||||
self.clusters.push(Cluster {
|
||||
start: base + a + gs,
|
||||
end: base + a + ge,
|
||||
start: base + a + cs,
|
||||
end: base + a + ce,
|
||||
adv,
|
||||
kind,
|
||||
style: si,
|
||||
@@ -972,20 +1073,36 @@ pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool)
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let doc = env.doc;
|
||||
let mut rp = doc.styles.resolve_para_in(&p.props, env.table.map(|t| &t.para));
|
||||
if let Some(spacing) = env.read_line_spacing.filter(|s| s.is_finite() && (1.0..=2.0).contains(s)) {
|
||||
rp.line_spacing = wordcraft_doc::props::LineSpacing::Multiple(spacing);
|
||||
}
|
||||
let para_style = p.props.style.as_deref();
|
||||
let table_chr = env.table.map(|t| &t.chr);
|
||||
// Indents in characters are measured in the paragraph mark's font size.
|
||||
if rp.indent_chars.iter().any(|c| *c != 0.0) {
|
||||
rp.set_char_unit(doc.styles.resolve_char_in(para_style, table_chr, &p.mark).size);
|
||||
}
|
||||
// List level indents apply unless the paragraph sets its own.
|
||||
// List level indents apply unless the paragraph sets its own, or, for a list from the style,
|
||||
// the style giving the list (or one based on it) does (`indents_over_list`). A paragraph
|
||||
// that switches its style's list off (`num: 0`) drops the styles' indents, as Word does.
|
||||
let own_left = p.props.indent_left.is_some() || p.props.indent_left_chars.is_some_and(|c| c != 0.0);
|
||||
let own_first = p.props.indent_first.is_some() || p.props.indent_first_chars.is_some_and(|c| c != 0.0);
|
||||
if let Some((_, lvl)) = &env.label {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
let (style_left, style_first) =
|
||||
if p.props.numbering.is_some() { (false, false) } else { doc.styles.indents_over_list(para_style.unwrap_or("Normal")) };
|
||||
if !own_left && !style_left {
|
||||
rp.indent_left = lvl.indent;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
if !own_first && !style_first {
|
||||
rp.indent_first = -lvl.hanging;
|
||||
}
|
||||
} else if p.props.numbering.is_some_and(|n| n.num == 0) {
|
||||
if !own_left {
|
||||
rp.indent_left = 0.0;
|
||||
}
|
||||
if !own_first {
|
||||
rp.indent_first = 0.0;
|
||||
}
|
||||
}
|
||||
let resolve = |c: &CharProps| -> Arc<ResolvedChar> { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) };
|
||||
let levels = bidi_levels(&p.text, rp.bidi);
|
||||
@@ -1110,9 +1227,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
if let Some((w, h, float)) = o.frame()
|
||||
&& float.wrap == wordcraft_doc::para::Wrap::Inline
|
||||
{
|
||||
// A picture wider than the line keeps its size, as in Word: it runs past
|
||||
// the margin and the page edge clips it (see `ParaEnv::fit_objects`).
|
||||
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
|
||||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let s = if w > maxw { maxw / w } else { 1.0 };
|
||||
let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 };
|
||||
// The line makes room for effects (shadows) around the picture too, and
|
||||
// for a rotated one's bounds, as Word does.
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
@@ -1678,7 +1797,10 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
||||
let mut any = false;
|
||||
let dropped = pl.drop_cap.map_or(0, |d| d.0);
|
||||
for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() {
|
||||
if c.kind == ClKind::Marker || c0 + k < dropped {
|
||||
// Spaces and tabs never make a line taller, whatever their size: Word sizes a line by
|
||||
// its other characters (a line of only them takes the paragraph mark's), measured in
|
||||
// Word for Windows with 9 pt text and 11–20 pt tabs, spaces and ideographic spaces.
|
||||
if matches!(c.kind, ClKind::Marker | ClKind::Space | ClKind::Tab) || c0 + k < dropped {
|
||||
continue;
|
||||
}
|
||||
if let Some(st) = pl.styles.get(c.style as usize) {
|
||||
@@ -1707,10 +1829,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
||||
asc = st.ascent;
|
||||
desc = st.descent;
|
||||
}
|
||||
// A list label can make the line taller above the baseline, never below it: Word keeps
|
||||
// the text's descent (measured in Word for Windows: a Symbol bullet's taller ascent
|
||||
// counts, a Courier New label's deeper descent doesn't).
|
||||
if first && let Some(lab) = &pl.label {
|
||||
for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) {
|
||||
asc = asc.max(st.ascent);
|
||||
desc = desc.max(st.descent);
|
||||
}
|
||||
}
|
||||
// Line spacing scales the text's height, never a picture's: a line is at least as tall
|
||||
|
||||
@@ -168,9 +168,13 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
hide_deleted: false,
|
||||
table: text.as_ref(),
|
||||
proofing: None,
|
||||
// Measurement uses a fixed environment (like show_hidden/proofing above).
|
||||
numeral: crate::NumeralMode::default(),
|
||||
exclusions: &[],
|
||||
eq_number: 0,
|
||||
read_line_spacing: None,
|
||||
grid: crate::para::Grid::default(),
|
||||
fit_objects: false,
|
||||
};
|
||||
let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT);
|
||||
if cell.props.vmerge != VMerge::Continue && !cell.props.text_direction.is_turned() {
|
||||
@@ -407,6 +411,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let mut rh = 0.0f32;
|
||||
// The tallest cell margins plus border bands in the row.
|
||||
let mut row_insets = 0.0f32;
|
||||
// Every cell an empty one with a hidden end-of-cell mark: the row is only its borders.
|
||||
let mut collapsed = true;
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = banded(t, style.as_ref(), ri);
|
||||
@@ -493,6 +499,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let (items, h) = if cell.props.vmerge == VMerge::Continue {
|
||||
(Vec::new(), 0.0)
|
||||
} else if turn.is_turned() {
|
||||
collapsed = false;
|
||||
// Turned text: its lines run along the cell's height. An exact row height sets
|
||||
// their length; otherwise they get the length the text needs unwrapped and the row
|
||||
// grows to fit (the second pass lays them out again if the row ends up taller).
|
||||
@@ -505,12 +512,20 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
natural_length(&probe).clamp(4.0, TURNED_MAX)
|
||||
}
|
||||
};
|
||||
let BoxLayout { items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
|
||||
let BoxLayout { items, height, floats_bottom, .. } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
|
||||
let across = height.max(floats_bottom);
|
||||
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
|
||||
(items, len)
|
||||
} else {
|
||||
let BoxLayout { mut items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top } =
|
||||
layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
|
||||
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
|
||||
// Windows: a row of empty such cells is only its borders).
|
||||
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
|
||||
height = last_top;
|
||||
} else {
|
||||
collapsed = false;
|
||||
}
|
||||
for it in &mut items {
|
||||
it.translate(x0 + margins[1], margins[0] + band_t);
|
||||
}
|
||||
@@ -545,7 +560,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
(Some(h), _) if h > 0.0 => rh.max(h + row_insets),
|
||||
_ => rh,
|
||||
};
|
||||
heights.push(rh.max(4.0));
|
||||
heights.push(if collapsed && !cells.is_empty() { rh.max(0.0) } else { rh.max(4.0) });
|
||||
rows.push(cells);
|
||||
}
|
||||
// Vertically merged cells: grow the last row of the merge if the content is taller.
|
||||
|
||||
+763
-25
@@ -44,6 +44,30 @@ fn long_paragraph_wraps_and_paginates() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_layout_reflows_into_viewport_height_pages_without_changing_print_layout() {
|
||||
let d = Document::from_text(&"A moderately long sentence that should wrap across reading columns. ".repeat(250));
|
||||
let opts = LayoutOptions { view: ViewMode::Read, web_width: 230.0, read_height: 390.0, ..Default::default() };
|
||||
let read = layout(&d, &mut LayoutCache::new(), &opts);
|
||||
assert!(read.pages.len() > 5, "reading column count: {}", read.pages.len());
|
||||
assert!(read.pages.iter().all(|p| p.w == 230.0 && p.h == 390.0));
|
||||
assert!(read.pages.iter().all(|p| p.body.w < p.w && p.body.h < p.h));
|
||||
assert_eq!(
|
||||
lines_of(&read),
|
||||
lines_of(&layout(&d, &mut LayoutCache::new(), &LayoutOptions { view: ViewMode::Web, web_width: 230.0, ..Default::default() }))
|
||||
);
|
||||
let print = lay(&d);
|
||||
assert_eq!(print.pages[0].w, d.sections()[0].1.page_w);
|
||||
assert_eq!(print.pages[0].h, d.sections()[0].1.page_h);
|
||||
// Window height changes page breaks, not the text, and a malformed dimension stays bounded.
|
||||
let short = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_height: 260.0, ..opts.clone() });
|
||||
assert!(short.pages.len() > read.pages.len());
|
||||
let spaced = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_line_spacing: Some(2.0), ..opts.clone() });
|
||||
assert!(spaced.pages.len() > read.pages.len(), "reader-only line spacing repaginates text");
|
||||
let invalid = layout(&d, &mut LayoutCache::new(), &LayoutOptions { web_width: f32::NAN, read_height: f32::INFINITY, ..opts });
|
||||
assert!(invalid.pages.iter().all(|p| p.w.is_finite() && p.h.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_and_caret_agree() {
|
||||
let d = Document::from_text("Hello world, this is WordCraft.\nSecond paragraph here.");
|
||||
@@ -166,29 +190,57 @@ fn hidden_text_adds_no_line_break_opportunity_when_not_shown() {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much longer laying out `doc(4n)` takes than `doc(n)` (fastest of three each, after a
|
||||
/// warm-up): ~4 when linear, ~16 when quadratic, whatever the machine.
|
||||
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout takes
|
||||
/// about 8x as long and a quadratic one about 64x, so a limit of 24x stays far from both, however
|
||||
/// busy the machine running the tests is.
|
||||
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs
|
||||
/// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both.
|
||||
const SCALE: usize = 8;
|
||||
|
||||
fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
|
||||
let fastest = |d: &Document| {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
layout(d, &mut LayoutCache::new(), opts);
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (small, big) = (doc(n), doc(SCALE * n));
|
||||
layout(&small, &mut LayoutCache::new(), opts);
|
||||
let (t1, tk) = (fastest(&small), fastest(&big));
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
/// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`.
|
||||
fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
|
||||
let (small, big) = (doc(n), doc(SCALE * n));
|
||||
cost_ratio(
|
||||
SCALE,
|
||||
|| {
|
||||
layout(&small, &mut LayoutCache::new(), opts);
|
||||
},
|
||||
|| {
|
||||
layout(&big, &mut LayoutCache::new(), opts);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() {
|
||||
// "hyphenation " n times with every space hidden: one joined word of 11n letters reaches the
|
||||
@@ -2290,8 +2342,10 @@ fn float_moved_to_a_new_page_sits_at_its_paragraph_top() {
|
||||
let l = lay(&d);
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
let top = first_line_top(&l.pages[1].items);
|
||||
// The space before counts once: the picture and the text both start below it.
|
||||
assert!((r.y - top).abs() < 0.5, "picture at {} but its paragraph's text starts at {top}", r.y);
|
||||
// The picture sits at the paragraph's start, above its space before, as in Word (`plan/word`
|
||||
// R22); the text starts below the space before.
|
||||
let body_top = d.last_section.margin_top;
|
||||
assert!((r.y - body_top).abs() < 0.5 && top >= body_top, "picture at {} but its paragraph starts at {body_top}", r.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2795,10 +2849,10 @@ fn graphic_paths_drop_segments_that_cannot_be_drawn() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_graphic_draws_shrunk_with_its_items() {
|
||||
fn graphic_items_scale_with_their_frame() {
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
// A 1000 x 500 pt chart is wider than the text: layout shrinks it, and its items with it.
|
||||
// A chart drawn for 1000 x 500 pt in a 500 x 250 pt frame: its items and strokes scale by half.
|
||||
let items = vec![GraphicItem::Shape {
|
||||
rect: [0.0, 0.0, 1000.0, 500.0],
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
@@ -2807,7 +2861,7 @@ fn oversized_graphic_draws_shrunk_with_its_items() {
|
||||
stroke_width: 4.0,
|
||||
}];
|
||||
let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None, chart: None, smart_art: None };
|
||||
let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
|
||||
let obj = InlineObject::Graphic { w: 500.0, h: 250.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
|
||||
let mut d = Document::from_text("Chart");
|
||||
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
|
||||
let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default()));
|
||||
@@ -2815,9 +2869,59 @@ fn oversized_graphic_draws_shrunk_with_its_items() {
|
||||
.iter()
|
||||
.find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None })
|
||||
.expect("a shape");
|
||||
assert!(r.w < 1000.0, "{r:?}");
|
||||
assert!((r.h / r.w - 0.5).abs() < 1e-3, "{r:?}");
|
||||
assert!((stroke / r.w - 4.0 / 1000.0).abs() < 1e-5, "stroke {stroke} at width {}", r.w);
|
||||
assert!((r.w - 500.0).abs() < 1e-3 && (r.h - 250.0).abs() < 1e-3, "{r:?}");
|
||||
assert!((stroke - 2.0).abs() < 1e-5, "stroke {stroke}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_picture_wider_than_a_text_box_is_fitted_to_it() {
|
||||
// Text boxes don't clip what runs past their sides, so a 400 x 100 pt picture in a 144 pt box
|
||||
// is fitted to the box's 129.6 pt inside its margins instead of spilling over the page.
|
||||
let mut d = Document::from_text("Before");
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
|
||||
p.insert_object(0, picture(400.0, 100.0, Default::default()), &Default::default()).unwrap();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(p)]);
|
||||
let shape = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
|
||||
let r = draws
|
||||
.iter()
|
||||
.find_map(|x| match x {
|
||||
display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the picture");
|
||||
assert!((r.w - 129.6).abs() < 0.1 && (r.h - 32.4).abs() < 0.1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_picture_wider_than_the_text_keeps_its_size() {
|
||||
// A 1000 x 100 pt picture in 468 pt of text: Word draws it at its size, past the margin, and the
|
||||
// page edge clips it; the line is as tall as the picture (docxide-pdf's case179).
|
||||
let mut d = Document::from_text("");
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.insert_object(0, picture(1000.0, 100.0, Default::default()), &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
let line = ¶.lines[0];
|
||||
assert!((line.baseline - line.top - 100.0).abs() < 0.01, "the picture keeps its height: {line:?}");
|
||||
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
|
||||
let r = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("the picture");
|
||||
assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}");
|
||||
}
|
||||
|
||||
/// The paragraph at `path` on page 0: its first line's x, its y and its line count.
|
||||
@@ -4006,3 +4110,637 @@ fn footer_page_total_needs_one_body_pass() {
|
||||
p.insert_object(n, field(), &Default::default()).unwrap();
|
||||
assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3));
|
||||
}
|
||||
|
||||
/// Bulgarian text (`w:lang` `bg-BG`) is hyphenated by the Bulgarian syllable rules; the same
|
||||
/// Cyrillic text in an English run is not hyphenated (the English data has nothing to say about it).
|
||||
#[test]
|
||||
fn bulgarian_text_is_hyphenated_by_its_own_rules() {
|
||||
let text = "Сричкопренасянето в професионалните документи задължително следва правописните правила, разработени от специалистите. ".repeat(6);
|
||||
let hyphenated = |d: &Document| -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for it in &lay(d).pages[0].items {
|
||||
if let Placed::Lines { para, .. } = it {
|
||||
for l in para.lines.iter().filter(|x| x.hyphen.is_some()) {
|
||||
let end = l.stop;
|
||||
let before = text.get(..end).unwrap_or_default();
|
||||
let word_start = before.rfind(' ').map_or(0, |i| i + 1);
|
||||
let after = text.get(end..).unwrap_or_default();
|
||||
let word_end = after.find(|c: char| !c.is_alphabetic()).unwrap_or(after.len());
|
||||
out.push((text[word_start..end].to_string(), after[..word_end].to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut d = Document::from_text(&text);
|
||||
d.settings.auto_hyphenation = true;
|
||||
assert!(hyphenated(&d).is_empty(), "Cyrillic in an English run: {:?}", hyphenated(&d));
|
||||
d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| c.lang = Some("bg-BG".into())).unwrap();
|
||||
let splits = hyphenated(&d);
|
||||
assert!(!splits.is_empty(), "no hyphenated lines");
|
||||
let lim = wordcraft_proof::hyphen::Limits::default();
|
||||
for (head, tail) in &splits {
|
||||
let word = format!("{head}{tail}");
|
||||
let at = head.chars().count();
|
||||
assert!(wordcraft_proof::hyphen::hyphen_points_in(&word, &lim, wordcraft_proof::ProofLang::Bg).contains(&at), "{head}-{tail}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaces and tabs never make a line taller in Word, whatever their size (measured in Word for
|
||||
/// Windows: 9 pt text with 11–20 pt tabs, spaces and ideographic spaces kept the 9 pt line; a
|
||||
/// line of only big spaces took the paragraph mark's height; a big letter or no-break space did
|
||||
/// grow it). Tables of contents with big tab runs were a third taller than Word's.
|
||||
#[test]
|
||||
fn spaces_and_tabs_do_not_make_a_line_taller() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let small = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
let pitch = |middle: &str, middle_props: &CharProps| {
|
||||
let mut d = Document::new();
|
||||
d.body = (0..3)
|
||||
.map(|i| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(&format!("L{i}A"), small.clone());
|
||||
let n = p.len();
|
||||
p.insert_text(n, middle, middle_props).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_text(n, "B", &small).unwrap();
|
||||
p.props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
p.mark = small.clone();
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let l = lay(&d);
|
||||
l.caret(&Pos::body(1, 0)).unwrap().top - l.caret(&Pos::body(0, 0)).unwrap().top
|
||||
};
|
||||
let plain = pitch(" ", &small);
|
||||
for (what, middle) in [("a tab", "\t"), ("a space", " "), ("two spaces", " "), ("an ideographic space", "\u{3000}")] {
|
||||
assert!((pitch(middle, &big) - plain).abs() < 0.01, "{what} at 20 pt: {} vs {plain}", pitch(middle, &big));
|
||||
}
|
||||
assert!(pitch("x", &big) > plain + 5.0, "a 20 pt letter does make it taller");
|
||||
assert!(pitch("\u{A0}", &big) > plain + 5.0, "so does a no-break space");
|
||||
}
|
||||
|
||||
/// A list label taller than its text makes the first line taller above the baseline only: Word
|
||||
/// keeps the text's descent below it (measured in Word for Windows, `plan/word` R18: a Symbol
|
||||
/// bullet's taller ascent counts, a Courier New label's deeper descent doesn't). NIH's bulleted
|
||||
/// items each came out a fifth of a point taller, enough to push a paragraph's last lines over
|
||||
/// the page bottom.
|
||||
#[test]
|
||||
fn a_list_label_adds_ascent_but_not_descent() {
|
||||
let first_line = |label_size: Option<f32>| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for a in &mut d.numbering.abstracts {
|
||||
for l in &mut a.levels {
|
||||
l.chr.size = label_size;
|
||||
}
|
||||
}
|
||||
let ten = wordcraft_doc::props::CharProps { size: Some(10.0), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Item", ten.clone());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
p.mark = ten;
|
||||
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().cloned() } else { None }).unwrap()
|
||||
};
|
||||
let plain = first_line(None);
|
||||
let big = first_line(Some(30.0));
|
||||
let below = |l: &crate::para::Line| l.top + l.height - l.baseline;
|
||||
assert!(big.baseline - big.top > plain.baseline - plain.top + 5.0, "the big label raises the baseline");
|
||||
assert!((below(&big) - below(&plain)).abs() < 0.01, "the text's descent: {} vs {}", below(&big), below(&plain));
|
||||
}
|
||||
|
||||
/// A cell's hidden end-of-cell mark (`w:hideMark`), measured in Word for Windows (`plan/word`
|
||||
/// R20, 9 cases): its empty last paragraph takes no room, spacing and all, so a row of such empty
|
||||
/// cells is only its borders; one ordinary empty cell keeps the row a line tall, and a paragraph
|
||||
/// with text is never hidden. NIH's Table A showed a blank row Word doesn't.
|
||||
#[test]
|
||||
fn a_hidden_cell_mark_takes_no_room() {
|
||||
let row3_top = |hide: [bool; 2], second: Option<&str>| {
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(3, 2, 300.0);
|
||||
for (r, text) in [(0, "R1"), (2, "R3")] {
|
||||
for c in 0..2 {
|
||||
t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
}
|
||||
}
|
||||
for (cell, hide) in t.rows[1].cells.iter_mut().zip(hide) {
|
||||
cell.props.hide_mark = hide;
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(text) = second {
|
||||
blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default())));
|
||||
}
|
||||
let mut mark = wordcraft_doc::Paragraph::with_text("", Default::default());
|
||||
mark.props.space_before = Some(10.0);
|
||||
blocks.push(wordcraft_doc::para_block(mark));
|
||||
cell.blocks = blocks;
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
|
||||
// Rows 1 and 2 together.
|
||||
at(2) - at(0)
|
||||
};
|
||||
// Row 1 is one line of text; row 2's height is what's left.
|
||||
let line = {
|
||||
let both = row3_top([false, false], Some("Tx"));
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(2, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R1", Default::default()))];
|
||||
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R3", Default::default()))];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
|
||||
assert!(both > at(1) - at(0));
|
||||
at(1) - at(0)
|
||||
};
|
||||
let row2 = |hide: [bool; 2], second: Option<&str>| row3_top(hide, second) - line;
|
||||
let hidden = row2([true, true], None);
|
||||
assert!(hidden < 1.5, "a row of hidden marks is only its borders: {hidden} vs a line of {line}");
|
||||
let mixed = row2([true, false], None);
|
||||
let plain = row2([false, false], None);
|
||||
assert!(plain > 5.0 && (mixed - plain).abs() < 0.01, "an ordinary empty cell keeps the row: {mixed} vs {plain}");
|
||||
let text = row2([true, true], Some("Tx"));
|
||||
assert!((text - line).abs() < 0.5, "text stays, the empty mark after it goes: {text} vs {line}");
|
||||
}
|
||||
|
||||
/// A text frame in a header, footer or cell (NIH's footers put the page number in one, centred,
|
||||
/// with the footer text wrapping around it) is placed apart: it takes no room in the flow, sits
|
||||
/// where its alignment says, and the text beside it keeps clear of it. WordCraft laid it out as
|
||||
/// an ordinary paragraph, a line of its own above the footer text.
|
||||
#[test]
|
||||
fn a_text_frame_in_a_cell_floats_beside_the_text() {
|
||||
use wordcraft_doc::props::TextFrame;
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
let mut framed = wordcraft_doc::Paragraph::with_text("7", Default::default());
|
||||
framed.props.frame = Some(TextFrame {
|
||||
x_align: Some("right".into()),
|
||||
h_anchor: "text".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
..Default::default()
|
||||
});
|
||||
let text = wordcraft_doc::Paragraph::with_text("Cell text", Default::default());
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(framed), wordcraft_doc::para_block(text)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap();
|
||||
let (frame, text) = (at(0), at(1));
|
||||
assert!((frame.top - text.top).abs() < 0.5, "the frame takes no room: {} vs {}", frame.top, text.top);
|
||||
assert!(frame.x > text.x + 200.0, "right-aligned in the cell: {} vs {}", frame.x, text.x);
|
||||
}
|
||||
|
||||
/// Where a list paragraph's text starts when its style has indents of its own (measured in Word,
|
||||
/// `plan/word` R16): the list level's indents go on top of the most derived style that gives the
|
||||
/// list, so that style's own indent (and those of styles based on it) win, its base styles' don't
|
||||
/// and the paragraph's own list wins over every style; a paragraph that switches the list off
|
||||
/// (`num: 0`) drops the styles' indents. NIH's Heading 3 (its own 0.9" on Heading 2's list) sat at
|
||||
/// the list's 0.6", and its "sample text" paragraphs with the list off at 0.9" instead of the margin.
|
||||
#[test]
|
||||
fn list_indents_and_style_indents_combine_like_word() {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
let text_x = |style: &str, own: ParaProps| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let style_of = |id: &str, based: Option<&str>, para: ParaProps| Style {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: based.map(Into::into),
|
||||
para,
|
||||
..Default::default()
|
||||
};
|
||||
let list = ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() };
|
||||
let ind = ParaProps { indent_left: Some(64.8), ..Default::default() };
|
||||
// List and indent in one style; list in the base, indent in the derived one; the reverse.
|
||||
d.styles.upsert(style_of("Both", None, ParaProps { indent_left: Some(64.8), ..list.clone() }));
|
||||
d.styles.upsert(style_of("ListBase", None, list.clone()));
|
||||
d.styles.upsert(style_of("IndentOnList", Some("ListBase"), ind.clone()));
|
||||
d.styles.upsert(style_of("IndentBase", None, ind));
|
||||
d.styles.upsert(style_of("ListOnIndent", Some("IndentBase"), list.clone()));
|
||||
let own =
|
||||
ParaProps { style: Some(style.into()), numbering: own.numbering.map(|n| NumRef { num: if n.num == 0 { 0 } else { num }, ..n }), ..own };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text", Default::default());
|
||||
p.props = own;
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
lay(&d).caret(&Pos::body(0, 0)).unwrap().x
|
||||
};
|
||||
let listed = || ParaProps { numbering: Some(NumRef { num: 1, level: 0 }), ..Default::default() };
|
||||
let off = || ParaProps { numbering: Some(NumRef { num: 0, level: 0 }), ..Default::default() };
|
||||
let cases = [
|
||||
("the style's own indent beats its list's", text_x("Both", ParaProps::default()), 72.0 + 64.8),
|
||||
("a style based on the list's style wins too", text_x("IndentOnList", ParaProps::default()), 72.0 + 64.8),
|
||||
("the list's style beats its base style", text_x("ListOnIndent", ParaProps::default()), 108.0),
|
||||
("the paragraph's own list beats every style", text_x("Both", listed()), 108.0),
|
||||
("list off: the style's indent goes too", text_x("Both", off()), 72.0),
|
||||
("list off drops an indent from any style", text_x("IndentBase", off()), 72.0),
|
||||
("list off keeps the paragraph's own indent", text_x("Both", ParaProps { indent_left: Some(10.0), ..off() }), 82.0),
|
||||
];
|
||||
for (what, got, want) in cases {
|
||||
assert!((got - want).abs() < 0.5, "{what}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep with next at a page bottom, measured in Word for Windows (`plan/word` R19, 20 cases): a
|
||||
/// paragraph that would fit on the page by itself moves over with the next one, but one that
|
||||
/// doesn't fit anyway breaks as usual, its last lines going over with the next paragraph (with
|
||||
/// Keep lines together it moves whole); a chain of kept paragraphs moves together. NIH's
|
||||
/// five-line kept paragraph left a third of a page empty.
|
||||
#[test]
|
||||
fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() {
|
||||
// Page of each of the kept paragraph's 5 lines and of the first paragraph after it.
|
||||
let pages = |fillers: usize, kept: ParaProps, next: ParaProps| {
|
||||
let mut d = Document::new();
|
||||
let plain = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let para = |text: &str, p: &ParaProps| {
|
||||
let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default());
|
||||
x.props = ParaProps { keep_next: p.keep_next, keep_lines: p.keep_lines, ..plain.clone() };
|
||||
wordcraft_doc::para_block(x)
|
||||
};
|
||||
d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect();
|
||||
d.body.push(para("Ka\nKb\nKc\nKd\nKe", &kept));
|
||||
d.body.push(para("NX", &next));
|
||||
d.body.push(para("NY", &plain));
|
||||
let l = lay(&d);
|
||||
let k = fillers;
|
||||
[0, 3, 6, 9, 12]
|
||||
.map(|off| l.caret(&Pos::body(k, off)).unwrap().page)
|
||||
.into_iter()
|
||||
.chain([l.caret(&Pos::body(k + 1, 0)).unwrap().page])
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let none = ParaProps::default();
|
||||
let kn = ParaProps { keep_next: Some(true), ..Default::default() };
|
||||
let kl = ParaProps { keep_lines: Some(true), ..kn.clone() };
|
||||
// Find where the kept paragraph just fits: its last line on page 1, the next one's on page 2.
|
||||
let fit = (20..80).find(|n| pages(*n, none.clone(), none.clone()) == [0, 0, 0, 0, 0, 1]).expect("a filler count that fills the page");
|
||||
assert_eq!(pages(fit, kn.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "fits by itself: moves with the next");
|
||||
let split = pages(fit + 1, none.clone(), none.clone());
|
||||
assert!(split[0] == 0 && split[4] == 1, "one more line splits it: {split:?}");
|
||||
assert_eq!(pages(fit + 1, kn.clone(), none.clone()), split, "doesn't fit anyway: breaks as usual");
|
||||
assert_eq!(pages(fit + 1, kl.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "with keep lines together it moves whole");
|
||||
assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together");
|
||||
}
|
||||
|
||||
/// An empty paragraph that only carries a section break before a continuous section takes no
|
||||
/// room, measured in Word for Windows (`plan/word` R21, 10 cases): the text after it follows right
|
||||
/// on, and columns balanced above it don't count it (two one-line paragraphs in two columns sit
|
||||
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
|
||||
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
|
||||
/// many continuous breaks.
|
||||
#[test]
|
||||
fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// "Pa", the section's last paragraph (`mark`), "Pb", "Pc" in the next section of `cols`
|
||||
// columns, then "Pd" after a second break.
|
||||
let tops = |mark: &str, cols: u32, start: SectionStart| {
|
||||
let mut d = Document::from_text(&format!("Pa\n{mark}\nPb\nPc\n\nPd"));
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
let mut s = SectionProps { start, ..Default::default() };
|
||||
s.columns.count = cols;
|
||||
d.para_mut(StoryRef::Body, &Path::top(4)).unwrap().section = Some(Box::new(s));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let l = lay(&d);
|
||||
[0, 2, 3, 5].map(|i| {
|
||||
let c = l.caret(&Pos::body(i, 0)).unwrap();
|
||||
(c.page, (c.top * 10.0).round() / 10.0, c.x)
|
||||
})
|
||||
};
|
||||
let one = tops("", 1, SectionStart::Continuous);
|
||||
let line = one[2].1 - one[1].1;
|
||||
assert!((one[1].1 - one[0].1 - line).abs() < 0.15, "Pb right under Pa: {one:?}");
|
||||
assert!((one[3].1 - one[2].1 - line).abs() < 0.15, "Pd right under Pc: {one:?}");
|
||||
let text = tops("Mark", 1, SectionStart::Continuous);
|
||||
assert!((text[1].1 - text[0].1 - 2.0 * line).abs() < 0.15, "a mark with text keeps its line: {text:?}");
|
||||
let two = tops("", 2, SectionStart::Continuous);
|
||||
assert_eq!(two[1].1, two[2].1, "balanced: Pb and Pc side by side: {two:?}");
|
||||
assert!(two[2].2 > two[1].2 + 100.0, "Pc in the second column: {two:?}");
|
||||
assert!((two[3].1 - two[1].1 - line).abs() < 0.15, "Pd a line below them: {two:?}");
|
||||
let new_page = tops("", 1, SectionStart::NextPage);
|
||||
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
|
||||
}
|
||||
|
||||
/// A floating object positioned relative to its paragraph is measured from the paragraph's start
|
||||
/// above its space before, measured in Word for Windows (`plan/word` R22, 10 cases: 0, 10 and 20
|
||||
/// pt before, offsets 0 and −7.2 pt; the previous paragraph's space after still counts). The SARP
|
||||
/// form's field boxes sat a space-before too low, over their labels.
|
||||
#[test]
|
||||
fn paragraph_relative_floats_start_above_the_space_before() {
|
||||
let shape_top = |before: f32, prev_after: f32, y: f32| {
|
||||
let float = wordcraft_doc::para::Float {
|
||||
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
|
||||
h_rel: wordcraft_doc::para::Anchor::Column,
|
||||
v_rel: wordcraft_doc::para::Anchor::Paragraph,
|
||||
y,
|
||||
..Default::default()
|
||||
};
|
||||
let shape = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(prev_after), ..Default::default() };
|
||||
let mut first = wordcraft_doc::Paragraph::with_text("First", Default::default());
|
||||
first.props = plain;
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
|
||||
p.text.push(wordcraft_doc::para::OBJ);
|
||||
p.objects = vec![shape];
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: Default::default() }];
|
||||
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), ..Default::default() };
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![wordcraft_doc::para_block(first), wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
let first_bottom = {
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
c.top + c.height
|
||||
};
|
||||
let top = l.pages[0].items.iter().find_map(|i| if let Placed::Shape { rect, .. } = i { Some(rect.y) } else { None }).unwrap();
|
||||
(top, first_bottom)
|
||||
};
|
||||
for before in [0.0, 10.0, 20.0] {
|
||||
for y in [0.0, -7.2] {
|
||||
let (top, first_bottom) = shape_top(before, 0.0, y);
|
||||
assert!((top - (first_bottom + y)).abs() < 1.0, "{before} pt before, offset {y}: {top} vs {}", first_bottom + y);
|
||||
}
|
||||
}
|
||||
let (top, first_bottom) = shape_top(0.0, 10.0, 0.0);
|
||||
assert!((top - (first_bottom + 10.0)).abs() < 1.0, "below the previous paragraph's space after: {top} vs {first_bottom}");
|
||||
}
|
||||
|
||||
/// Unequal columns (a narrow label column and a wide text column, as in NIH's SARP form): text
|
||||
/// after a column break is broken for the column it lands in. WordCraft broke it for the narrow
|
||||
/// column it started in, so a paragraph took a dozen lines a word or two wide.
|
||||
#[test]
|
||||
fn text_after_a_column_break_takes_the_new_column_s_width() {
|
||||
let mut d = Document::new();
|
||||
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: vec![(67.0, 52.0), (348.0, 0.0)] };
|
||||
let brk = wordcraft_doc::para::COLUMN_BREAK;
|
||||
let text = format!("Label{brk}Provide a description of the planned recruitment methods including use of contact lists and databases.");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))];
|
||||
let l = lay(&d);
|
||||
let lines: Vec<(f32, usize)> =
|
||||
l.pages[0].items.iter().filter_map(|it| if let Placed::Lines { x, l0, l1, .. } = it { Some((*x, l1 - l0)) } else { None }).collect();
|
||||
let (wide_x, wide_lines) = lines.last().copied().unwrap();
|
||||
assert!(wide_x > 72.0 + 67.0, "the text is in the second column: {lines:?}");
|
||||
assert!(wide_lines <= 3, "broken for the 348 pt column, not the 67 pt one: {lines:?}");
|
||||
}
|
||||
|
||||
/// Space before at the top of a page, measured in Word for Windows (32 cases): a paragraph that
|
||||
/// ends with a page break keeps its mark with the break (nothing of it goes to the new page) and the
|
||||
/// next paragraph gets no space before there; a paragraph that starts with a page break starts on
|
||||
/// the new page with its space before; a page break before keeps the space before in compatibility
|
||||
/// modes up to 14 (less the previous paragraph's space after) and drops it in mode 15.
|
||||
#[test]
|
||||
fn space_before_at_the_top_of_a_page_like_word() {
|
||||
let para = |text: &str, before: f32, pbb: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
|
||||
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), page_break_before: Some(pbb), ..Default::default() };
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let probe_top = |blocks: Vec<std::sync::Arc<wordcraft_doc::Block>>, mode: u32| {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let n = blocks.len();
|
||||
d.body = blocks;
|
||||
let l = lay(&d);
|
||||
let c = l.caret(&Pos::body(n - 1, 0)).unwrap();
|
||||
(c.page, c.top)
|
||||
};
|
||||
let plain_top = probe_top(vec![para("first", 0.0, false), para("PROBE", 0.0, true)], 15).1;
|
||||
let brk = wordcraft_doc::para::PAGE_BREAK;
|
||||
// A paragraph ending with a page break, then one with 24 pt before: no space, in both modes.
|
||||
for mode in [12, 15] {
|
||||
let (page, top) = probe_top(vec![para(&format!("first{brk}"), 0.0, false), para("PROBE", 24.0, false)], mode);
|
||||
assert_eq!(page, 1, "mode {mode}");
|
||||
assert!((top - plain_top).abs() < 0.05, "mode {mode}: the mark stays with the break and the space goes: {top} vs {plain_top}");
|
||||
}
|
||||
// A paragraph starting with a page break: its space before on the new page.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para("first", 0.0, false), para(&format!("{brk}PROBE"), 24.0, false)];
|
||||
let l = lay(&d);
|
||||
let c = l.caret(&Pos::body(1, brk.len_utf8())).unwrap();
|
||||
assert_eq!(c.page, 1);
|
||||
assert!((c.top - plain_top - 24.0).abs() < 0.05, "{} vs {}", c.top, plain_top + 24.0);
|
||||
// Page break before: kept up to mode 14, dropped in 15.
|
||||
let (_, kept) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 14);
|
||||
let (_, dropped) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 15);
|
||||
assert!((kept - plain_top - 24.0).abs() < 0.05 && (dropped - plain_top).abs() < 0.05, "{kept} {dropped} {plain_top}");
|
||||
// After a page break before (mode 14) the previous paragraph's space after comes off: 24 - 10.
|
||||
let mut first = wordcraft_doc::Paragraph::with_text("first", Default::default());
|
||||
first.props = ParaProps { space_before: Some(0.0), space_after: Some(10.0), ..Default::default() };
|
||||
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first.clone()), para("PROBE", 24.0, true)], 14);
|
||||
assert!((top - plain_top - 14.0).abs() < 0.05, "{top} vs {}", plain_top + 14.0);
|
||||
// More after than before: nothing left.
|
||||
first.props.space_after = Some(30.0);
|
||||
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first), para("PROBE", 24.0, true)], 14);
|
||||
assert!((top - plain_top).abs() < 0.05, "{top} vs {plain_top}");
|
||||
// The caret can still reach the end of a paragraph whose mark stayed with its page break.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para(&format!("first{brk}"), 0.0, false), para("next", 0.0, false)];
|
||||
let l = lay(&d);
|
||||
assert!(l.caret(&Pos::body(0, "first".len() + brk.len_utf8())).is_some(), "caret after the break");
|
||||
}
|
||||
|
||||
/// Word kerns a run only when Kerning for fonts (`w:kern`) is set and the font is at least that
|
||||
/// size; WordCraft kerned everything, so its lines held more text than Word's (measured in Word for
|
||||
/// Windows with Calibri at 14 pt: no `w:kern`, `w:kern="0"` and a threshold above the size are
|
||||
/// all unkerned).
|
||||
#[test]
|
||||
fn kerning_follows_kerning_for_fonts_like_word() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let width = |kern: Option<f32>| {
|
||||
let mut d = Document::new();
|
||||
let chr = CharProps { size: Some(14.0), kern, ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("AVAVAV To Wo Ty Yo AWAY", chr))];
|
||||
let l = lay(&d);
|
||||
let end = d.para_at(&Pos::body(0, 0)).map_or(0, |p| p.len());
|
||||
l.caret(&Pos::body(0, end)).unwrap().x - l.caret(&Pos::body(0, 0)).unwrap().x
|
||||
};
|
||||
let plain = width(Some(0.0));
|
||||
assert!(width(Some(1.0)) < plain - 1.0, "kerned from 1 pt: {} vs {plain}", width(Some(1.0)));
|
||||
assert!((width(Some(14.0)) - width(Some(1.0))).abs() < 0.01, "a threshold equal to the size kerns");
|
||||
assert!((width(Some(15.0)) - plain).abs() < 0.01, "a threshold above the size doesn't kern");
|
||||
// A new document kerns from 1 pt, like Word's Normal template.
|
||||
assert!((width(None) - width(Some(1.0))).abs() < 0.01, "new documents kern");
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.kern = None;
|
||||
assert!(d.styles.resolve_char(None, &Default::default()).kern == 0.0, "no Kerning for fonts: not kerned");
|
||||
}
|
||||
|
||||
/// Word's automatic (HTML-style) paragraph spacing, measured in Word for Windows: 14 pt at any
|
||||
/// size; none before the first paragraph of the document or a cell, none after the last one of a
|
||||
/// cell; next to another paragraph's space the larger of the two counts.
|
||||
#[test]
|
||||
fn automatic_paragraph_spacing_like_word() {
|
||||
let auto = |before: bool, after: bool| ParaProps {
|
||||
space_before: Some(5.0),
|
||||
space_after: Some(5.0),
|
||||
space_before_auto: before.then_some(true),
|
||||
space_after_auto: after.then_some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
let tops = |props: [ParaProps; 3]| {
|
||||
let mut d = Document::new();
|
||||
d.body = ["FIRST", "SECOND", "THIRD"]
|
||||
.iter()
|
||||
.zip(props)
|
||||
.map(|(t, p)| {
|
||||
let mut para = wordcraft_doc::Paragraph::with_text(t, Default::default());
|
||||
para.props = p;
|
||||
wordcraft_doc::para_block(para)
|
||||
})
|
||||
.collect();
|
||||
let l = lay(&d);
|
||||
[0, 1, 2].map(|i| l.caret(&Pos::body(i, 0)).unwrap().top)
|
||||
};
|
||||
let base = tops([plain.clone(), plain.clone(), plain.clone()]);
|
||||
let line = base[1] - base[0];
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
// Space after: 14 pt.
|
||||
let t = tops([plain.clone(), auto(false, true), plain.clone()]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
// Space before: 14 pt, but none for the document's first paragraph.
|
||||
let t = tops([auto(true, false), auto(true, false), plain.clone()]);
|
||||
assert!(near(t[0], base[0]), "no automatic space at the top of the document");
|
||||
assert!(near(t[1] - t[0], line + 14.0), "{t:?}");
|
||||
// Two automatic spaces meet: 14 pt, not 28; an automatic one beside 10 pt: 14.
|
||||
let t = tops([plain.clone(), auto(true, true), auto(true, true)]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
let ten = ParaProps { space_before: Some(10.0), ..plain.clone() };
|
||||
let t = tops([plain.clone(), auto(false, true), ten]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
// Explicitly off: the measured spacing again.
|
||||
let off = ParaProps { space_after_auto: Some(false), ..auto(false, false) };
|
||||
let t = tops([plain.clone(), off, plain.clone()]);
|
||||
assert!(near(t[2] - t[1], line + 5.0), "{t:?}");
|
||||
}
|
||||
|
||||
/// In a table cell automatic spacing drops at the cell's top and bottom (Word for Windows).
|
||||
#[test]
|
||||
fn automatic_paragraph_spacing_drops_at_cell_edges() {
|
||||
let cell_tops = |auto: bool| {
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = ["SECOND", "THIRD"]
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(s, Default::default());
|
||||
p.props = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
space_before_auto: auto.then_some(true),
|
||||
space_after_auto: auto.then_some(true),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Para(wordcraft_doc::Paragraph::with_text("Below", Default::default())))
|
||||
.unwrap();
|
||||
let l = lay(&d);
|
||||
let in_cell = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap().top;
|
||||
(in_cell(0), in_cell(1), l.caret(&Pos::body(2, 0)).unwrap().top)
|
||||
};
|
||||
let (a0, a1, a_below) = cell_tops(true);
|
||||
let (p0, p1, p_below) = cell_tops(false);
|
||||
assert!((a0 - p0).abs() < 0.05, "no automatic space at the top of the cell");
|
||||
assert!((a1 - a0 - (p1 - p0) - 14.0).abs() < 0.05, "14 pt between the cell's paragraphs");
|
||||
assert!((a_below - a1 - (p_below - p1)).abs() < 0.05, "none after the cell's last paragraph");
|
||||
}
|
||||
|
||||
/// Automatic spacing inside a list, measured in Word for Windows (`plan/word` R17, 16 cases): none
|
||||
/// between two list items, of one list or two, at any level, whichever item asks for it; the first
|
||||
/// item keeps its automatic space before and the last its space after, a paragraph with its list
|
||||
/// switched off in between keeps both, and measured spacing stays. NIH's bulleted IND reports
|
||||
/// (each item automatic before and after) were 14 pt apart instead of touching.
|
||||
#[test]
|
||||
fn automatic_spacing_drops_between_list_items() {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let auto = ParaProps {
|
||||
space_before: Some(5.0),
|
||||
space_after: Some(5.0),
|
||||
space_before_auto: Some(true),
|
||||
space_after_auto: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
let tops = |items: [ParaProps; 3], in_cell: bool| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let other = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let fix = |p: ParaProps| ParaProps {
|
||||
numbering: p.numbering.map(|n| NumRef {
|
||||
num: match n.num {
|
||||
0 => 0,
|
||||
1 => num,
|
||||
_ => other,
|
||||
},
|
||||
..n
|
||||
}),
|
||||
..p
|
||||
};
|
||||
let paras: Vec<_> = std::iter::once(plain.clone())
|
||||
.chain(items.map(fix))
|
||||
.chain(std::iter::once(plain.clone()))
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let mut para = wordcraft_doc::Paragraph::with_text(&format!("P{i}"), Default::default());
|
||||
para.props = p;
|
||||
wordcraft_doc::para_block(para)
|
||||
})
|
||||
.collect();
|
||||
if in_cell {
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = paras;
|
||||
d.body = vec![wordcraft_doc::Block::Table(t).into()];
|
||||
} else {
|
||||
d.body = paras;
|
||||
}
|
||||
let at = |i: u32| if in_cell { Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, i]), off: 0 } } else { Pos::body(i as usize, 0) };
|
||||
let l = lay(&d);
|
||||
[0, 1, 2, 3, 4].map(|i| l.caret(&at(i)).unwrap().top)
|
||||
};
|
||||
let item = |num: u32, level: u8, p: &ParaProps| ParaProps { numbering: Some(NumRef { num, level }), ..p.clone() };
|
||||
for in_cell in [false, true] {
|
||||
let base = tops([plain.clone(), plain.clone(), plain.clone()], in_cell);
|
||||
let line = base[1] - base[0];
|
||||
let gaps = |t: [f32; 5]| [1, 2, 3, 4].map(|i| ((t[i] - t[i - 1] - line) * 10.0).round() / 10.0);
|
||||
let one = item(1, 0, &auto);
|
||||
let cases = [
|
||||
("one list", [one.clone(), one.clone(), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("two lists", [one.clone(), item(2, 0, &auto), item(2, 0, &auto)], [14.0, 0.0, 0.0, 14.0]),
|
||||
("two levels", [one.clone(), item(1, 1, &auto), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("a plain item between", [one.clone(), item(1, 0, &plain), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("list off between", [one.clone(), item(0, 0, &auto), one.clone()], [14.0, 14.0, 14.0, 14.0]),
|
||||
("no list", [auto.clone(), auto.clone(), auto.clone()], [14.0, 14.0, 14.0, 14.0]),
|
||||
];
|
||||
for (what, items, want) in cases {
|
||||
assert_eq!(gaps(tops(items, in_cell)), want, "{what} (in a cell: {in_cell})");
|
||||
}
|
||||
let fourteen = ParaProps { space_before: Some(14.0), ..plain.clone() };
|
||||
assert_eq!(
|
||||
gaps(tops([item(1, 0, &fourteen), item(1, 0, &fourteen), item(1, 0, &fourteen)], in_cell)),
|
||||
[14.0, 14.0, 14.0, 0.0],
|
||||
"measured spacing stays"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Numeral display modes (Word › File › Options › Advanced › Numeral): Western digits,
|
||||
//! Hindi digits, or context-following digits — display only, the text is untouched.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
fn lay_opts(text: &str, rtl_para: bool, numeral: NumeralMode) -> DocLayout {
|
||||
let mut d = Document::from_text(text);
|
||||
if rtl_para {
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
|
||||
}
|
||||
let mut c = LayoutCache::new();
|
||||
layout(&d, &mut c, &LayoutOptions { numeral, ..LayoutOptions::default() })
|
||||
}
|
||||
|
||||
/// Glyph ids of the first line's clusters covering `text`, in cluster order.
|
||||
fn line_gids(l: &DocLayout, text: &str) -> Vec<Vec<u32>> {
|
||||
for p in &l.pages {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
|
||||
let line = ¶.lines[0];
|
||||
let mut out = Vec::new();
|
||||
for k in line.c0..line.c1 {
|
||||
let cl = ¶.clusters[k];
|
||||
assert!(cl.start <= cl.end && cl.end <= text.len(), "cluster indexes the original text");
|
||||
let gids: Vec<u32> = para.glyphs[cl.g0 as usize..cl.g1 as usize].iter().map(|g| g.gid).collect();
|
||||
out.push(gids);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("body paragraph not laid out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_the_four_modes_any_case() {
|
||||
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
|
||||
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
|
||||
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
|
||||
assert_eq!(NumeralMode::parse("system"), Some(NumeralMode::System));
|
||||
assert_eq!(NumeralMode::parse("Hindi"), Some(NumeralMode::Hindi));
|
||||
assert_eq!(NumeralMode::parse("CONTEXT"), Some(NumeralMode::Context));
|
||||
assert_eq!(NumeralMode::parse("western"), None);
|
||||
assert_eq!(NumeralMode::parse(""), None);
|
||||
assert_eq!(NumeralMode::parse("hindi "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_substitutes_digits_both_ways() {
|
||||
for d in '0'..='9' {
|
||||
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
|
||||
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
|
||||
assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction");
|
||||
assert_eq!(NumeralMode::Arabic.map(hindi, false), d);
|
||||
assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways");
|
||||
assert_eq!(NumeralMode::Arabic.map(d, false), d);
|
||||
assert_eq!(NumeralMode::Context.map(d, true), hindi);
|
||||
assert_eq!(NumeralMode::Context.map(d, false), d);
|
||||
assert_eq!(NumeralMode::Context.map(hindi, true), hindi);
|
||||
assert_eq!(NumeralMode::Context.map(hindi, false), d);
|
||||
}
|
||||
// Letters, punctuation and Persian digits are never touched.
|
||||
for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] {
|
||||
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
|
||||
assert_eq!(m.map(c, false), c, "{m:?} leaves {c:?} alone (ltr)");
|
||||
assert_eq!(m.map(c, true), c, "{m:?} leaves {c:?} alone (rtl)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_the_given_system_locale() {
|
||||
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
|
||||
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
|
||||
}
|
||||
for tag in ["ar", "ar-EG", "fa_IR.UTF-8", "ur-PK", "ckb-IQ", "AR-sa"] {
|
||||
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Hindi, "{tag}");
|
||||
}
|
||||
for tag in ["en-US", "C", "", "tr_TR", "ary"] {
|
||||
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Arabic, "{tag}");
|
||||
}
|
||||
assert_eq!(NumeralMode::System.resolve(None), NumeralMode::Arabic, "unknown locale: Western digits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mode_on_a_very_long_digit_run_stays_linear() {
|
||||
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
|
||||
let text = format!("سنة {}", "1 ".repeat(100_000));
|
||||
let start = std::time::Instant::now();
|
||||
let l = lay_opts(&text, false, NumeralMode::Context);
|
||||
assert!(!l.pages.is_empty());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(60), "took {:?}", start.elapsed());
|
||||
// The digits follow the Arabic word before them.
|
||||
let short = "سنة 1 2 3";
|
||||
assert_eq!(line_gids(&lay_opts(short, false, NumeralMode::Context), short), line_gids(&lay_opts(short, false, NumeralMode::Hindi), short));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
|
||||
let text = "Year 2024!";
|
||||
let latin = lay_opts(text, false, NumeralMode::Arabic);
|
||||
let hindi = lay_opts(text, false, NumeralMode::Hindi);
|
||||
let a = line_gids(&latin, text);
|
||||
let h = line_gids(&hindi, text);
|
||||
assert_eq!(a.len(), h.len(), "same clusters in both modes");
|
||||
assert!(!a.is_empty());
|
||||
// Letter clusters draw identical glyphs; digit clusters draw different ones.
|
||||
for (n, (ga, gh)) in a.iter().zip(h.iter()).enumerate() {
|
||||
let ch = text.chars().nth(n).unwrap();
|
||||
if ch.is_ascii_digit() {
|
||||
assert_ne!(ga, gh, "digit {ch:?} reshaped in Hindi mode");
|
||||
assert!(!gh.is_empty(), "Hindi digit draws something");
|
||||
} else {
|
||||
assert_eq!(ga, gh, "{ch:?} untouched by Hindi mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_mode_converts_hindi_digits_back() {
|
||||
let text = "عام ٢٠٢٤";
|
||||
let hindi = lay_opts(text, true, NumeralMode::Hindi);
|
||||
let arabic = lay_opts(text, true, NumeralMode::Arabic);
|
||||
let h = line_gids(&hindi, text);
|
||||
let a = line_gids(&arabic, text);
|
||||
assert_eq!(h.len(), a.len());
|
||||
let mut changed = 0;
|
||||
for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() {
|
||||
if text.chars().nth(n).unwrap() == '٢' {
|
||||
assert_ne!(gh, ga);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
assert!(changed > 0, "typed Hindi digits display Western in Arabic mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mode_follows_run_direction() {
|
||||
// Right-to-left paragraph: digits behave like Hindi mode.
|
||||
let rtl = "سنة 2024";
|
||||
let ctx = line_gids(&lay_opts(rtl, true, NumeralMode::Context), rtl);
|
||||
let hin = line_gids(&lay_opts(rtl, true, NumeralMode::Hindi), rtl);
|
||||
assert_eq!(ctx, hin, "digits in RTL text show Hindi in Context mode");
|
||||
// Left-to-right paragraph: digits behave like Arabic mode.
|
||||
let ltr = "Year 2024";
|
||||
let ctx = line_gids(&lay_opts(ltr, false, NumeralMode::Context), ltr);
|
||||
let ara = line_gids(&lay_opts(ltr, false, NumeralMode::Arabic), ltr);
|
||||
assert_eq!(ctx, ara, "digits in LTR text show Western in Context mode");
|
||||
// Mixed paragraph: digits next to Arabic show Hindi even in a left-to-right paragraph.
|
||||
let mixed = "Year سنة 2024";
|
||||
let ctx = line_gids(&lay_opts(mixed, false, NumeralMode::Context), mixed);
|
||||
let hin = line_gids(&lay_opts(mixed, false, NumeralMode::Hindi), mixed);
|
||||
assert_eq!(ctx, hin, "digits by Arabic text show Hindi in Context mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_range_maps_display_bytes_to_original() {
|
||||
use crate::para::remap_range;
|
||||
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
|
||||
let orig = "a5ب";
|
||||
let disp = "a٥ب";
|
||||
let map_starts: Vec<usize> = disp.char_indices().map(|(i, _)| i).collect();
|
||||
let orig_of: Vec<usize> = orig.char_indices().map(|(i, _)| i).collect();
|
||||
let mut orig_of = orig_of;
|
||||
orig_of.push(orig.len());
|
||||
// Display "a" [0,1) → original [0,1); "٥" [1,3) → original [1,2); "ب" [3,5) → [2,4).
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 1), (0, 1));
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 1, 3), (1, 2));
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 5), (2, 4));
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 5), (0, 4));
|
||||
// Hostile inputs never panic and stay in range.
|
||||
assert_eq!(remap_range(&[], &[], 0, 0, 0), (0, 0));
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4));
|
||||
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2));
|
||||
}
|
||||
@@ -133,3 +133,48 @@ fn hostile_link_chains_stay_bounded() {
|
||||
assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
|
||||
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
|
||||
// its text; with Word's default margins its second line would be cut.
|
||||
let two = |h: f32, body: TextBody| {
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, h, body);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let shown: usize = lines(&l, id).iter().map(|(.., l0, l1)| l1 - l0).sum();
|
||||
(shown, lines(&l, id).first().map(|(x, y, ..)| (*x, *y)), frame_of(&l, id))
|
||||
};
|
||||
// How deep the two lines reach without margins, from a box with room to spare.
|
||||
let zero = TextBody { inset: Some([0; 4]), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, 400.0, zero);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let r = frame_of(&l, id);
|
||||
let need = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(s), para, l0, l1, y, .. } if *s == id => {
|
||||
let (first, last) = (para.lines.get(*l0)?, para.lines.get(l1.checked_sub(1)?)?);
|
||||
Some(y + last.top + last.height - first.top - r.y)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(need > 10.0, "{need}");
|
||||
let (shown, at, r) = two(need + 1.0, zero);
|
||||
assert_eq!(shown, 2, "both lines fit without margins");
|
||||
let (x, y) = at.unwrap();
|
||||
assert!((x - r.x).abs() < 0.5 && (y - r.y).abs() < 0.5, "text starts at the box's corner: {x},{y} in {r:?}");
|
||||
assert_eq!(two(need + 1.0, TextBody::default()).0, 1, "Word's default margins cut the second line");
|
||||
let (shown, ..) = two(need / 2.0, TextBody { auto_fit: true, ..Default::default() });
|
||||
assert_eq!(shown, 2, "a box that resizes to fit its text shows it all");
|
||||
// Hostile margins wider than the box still lay out.
|
||||
let (shown, ..) = two(20.0, TextBody { inset: Some([u32::MAX; 4]), ..Default::default() });
|
||||
assert!(shown >= 1);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use wordcraft_doc::props::{TextDirection, TextVert, VAlign};
|
||||
use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::{BOX_INSET_X, BOX_INSET_Y, Ctx, Placed, fit_box, layout_box};
|
||||
use crate::{Ctx, Placed, fit_box, layout_box};
|
||||
|
||||
/// Linked text boxes for one layout, worked out when a linked box is first laid out.
|
||||
#[derive(Default)]
|
||||
@@ -54,12 +54,14 @@ pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs `vert`.
|
||||
fn frame(vert: TextVert, w: f32, h: f32) -> (f32, f32) {
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs as `body`
|
||||
/// says, inside its internal margins.
|
||||
fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
|
||||
let (along, across) = match vert.turn() {
|
||||
TextDirection::Horizontal => (w - 2.0 * BOX_INSET_X, h - 2.0 * BOX_INSET_Y),
|
||||
_ => (h - 2.0 * BOX_INSET_Y, w - 2.0 * BOX_INSET_X),
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (along, across) = match body.vert.turn() {
|
||||
TextDirection::Horizontal => (w - l - r, h - t - b),
|
||||
_ => (h - t - b, w - l - r),
|
||||
};
|
||||
(along.max(12.0), across)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth:
|
||||
/// share of a linked chain's, cut to what fits, aligned and turned as the box says.
|
||||
pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec<Placed> {
|
||||
let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default();
|
||||
let (len, across) = frame(body.vert, rect.w, rect.h);
|
||||
let (len, across) = frame(&body, rect.w, rect.h);
|
||||
let items = match chain_share(ctx, id, depth) {
|
||||
Some(items) => items,
|
||||
None => {
|
||||
@@ -107,8 +109,9 @@ pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let inner = lay(ctx, id, &blocks, len, body.vert, depth);
|
||||
ctx.boxes.leave();
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
fit_box(inner, across)
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word; a box that resizes
|
||||
// to fit its text shows it all (#743).
|
||||
if body.auto_fit { inner } else { fit_box(inner, across) }
|
||||
}
|
||||
};
|
||||
place(items, &body, rect, across)
|
||||
@@ -124,11 +127,12 @@ fn place(mut items: Vec<Placed>, body: &TextBody, rect: Rect, across: f32) -> Ve
|
||||
VAlign::Bottom => (across - used).max(0.0),
|
||||
};
|
||||
let turn = body.vert.turn();
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (ox, oy) = match turn {
|
||||
TextDirection::Horizontal => (rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y + off),
|
||||
TextDirection::Horizontal => (rect.x + l, rect.y + t + off),
|
||||
// Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left.
|
||||
TextDirection::Down => (rect.right() - BOX_INSET_X - off, rect.y + BOX_INSET_Y),
|
||||
TextDirection::Up => (rect.x + BOX_INSET_X + off, rect.bottom() - BOX_INSET_Y),
|
||||
TextDirection::Down => (rect.right() - r - off, rect.y + t),
|
||||
TextDirection::Up => (rect.x + l + off, rect.bottom() - b),
|
||||
};
|
||||
for it in &mut items {
|
||||
it.turn(turn, ox, oy);
|
||||
@@ -204,7 +208,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
};
|
||||
let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default();
|
||||
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let (len, across) = frame(body.vert, w, h);
|
||||
let (len, across) = frame(&body, w, h);
|
||||
let key = (len.to_bits(), body.vert);
|
||||
let items = match layouts.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, items)) => items.clone(),
|
||||
|
||||
+24
-6
@@ -12,8 +12,11 @@ pub mod tools;
|
||||
pub use backend::{Backend, Remote};
|
||||
pub use server::Server;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_engine::file_access::{self, AutomationRoots};
|
||||
|
||||
/// Methods that need the desktop app.
|
||||
pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7981` and run `wordcraft-cli mcp --connect 127.0.0.1:7981`";
|
||||
@@ -21,16 +24,18 @@ pub const NEEDS_APP: &str = "needs the desktop app: start `wordcraft --control 7
|
||||
/// An in-process session.
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Headless { session: Session::new(wordcraft_doc::Document::new()) }
|
||||
impl Headless {
|
||||
/// Confine every file the session's commands read and write to `roots`
|
||||
/// (`wordcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" | "command" => {
|
||||
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
|
||||
@@ -55,6 +60,19 @@ impl Backend for Headless {
|
||||
other => self.session.run(other, ¶ms).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Headless { session: Session::new(wordcraft_doc::Document::new()), roots: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -107,3 +107,15 @@ fn render_and_screenshot_through_the_app_use_no_temp_file() {
|
||||
assert!(params.get("path").is_none(), "{tool} asked for a file: {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #40: a headless session never reads or writes the person's custom dictionary file: an agent's
|
||||
/// Add to Dictionary stays in memory (only the desktop app saves the dictionary).
|
||||
#[test]
|
||||
fn headless_sessions_keep_the_dictionary_in_memory() {
|
||||
let mut h = Headless::default();
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "execute", json!({"command": "review.addToDictionary", "params": {"word": "zxqmcpword"}}));
|
||||
h.session.run("review.addToDictionary", &json!({"word": "zxqmcpword2"})).expect("added");
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! `wordcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` (#769): through
|
||||
//! the MCP protocol, files open and load only from inside the read root and are saved and
|
||||
//! exported only inside the write root; paths that leave a root (`..`, another folder, a sibling
|
||||
//! with the same prefix, a link) are refused, and nothing is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::file_access::AutomationRoots;
|
||||
use wordcraft_mcp::{Headless, Server};
|
||||
|
||||
/// A 1x1 PNG, so `insert.picture` has something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A fresh folder holding `in` (the read root, with `a.txt`, `people.csv` and `pic.png`), `out` (the
|
||||
/// write root), `outside` (with `secret.txt`) and `in2`, and a server confined to `in` and `out`
|
||||
/// (either may be left out).
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!("wc-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.txt"), "Inside text").unwrap();
|
||||
std::fs::write(base.join("in/people.csv"), "Name\nAda\n").unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap();
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/t.docx"), "x").unwrap();
|
||||
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
(Server::new(Box::new(Headless::default().with_automation_roots(roots))), base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, the first text block).
|
||||
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
|
||||
let result = &reply["result"];
|
||||
let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string();
|
||||
(result["isError"] == true, text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut Server, name: &str, args: Value) -> String {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(!error, "{name} {args}: {text}");
|
||||
text
|
||||
}
|
||||
|
||||
fn refused(server: &mut Server, name: &str, args: Value, why: &str) {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
|
||||
}
|
||||
|
||||
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
|
||||
call(server, "execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
|
||||
|
||||
refused(&mut server, "open_document", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root");
|
||||
refused(
|
||||
&mut server,
|
||||
"open_document",
|
||||
json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}),
|
||||
"outside the read root",
|
||||
);
|
||||
refused(&mut server, "open_document", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root");
|
||||
// The refused open left the document it had.
|
||||
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
|
||||
// The write root isn't readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.txt"), "d").unwrap();
|
||||
refused(&mut server, "open_document", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root");
|
||||
// A relative path is the working directory's (the crate's folder here): outside.
|
||||
refused(&mut server, "open_document", json!({"path": "Cargo.toml"}), "outside the read root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_read_a_path_are_held_to_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
let outside = s(&base.join("outside/secret.txt"));
|
||||
// The injected-document scenario from the issue: pull a file in from outside, then read it back.
|
||||
for (command, params) in [
|
||||
("insert.textFromFile", json!({"path": outside})),
|
||||
("insert.object", json!({"path": outside})),
|
||||
("insert.picture", json!({"path": s(&base.join("outside/pic.png"))})),
|
||||
("mailings.recipients", json!({"path": outside})),
|
||||
("file.newFromTemplate", json!({"path": outside})),
|
||||
("review.compare", json!({"path": outside})),
|
||||
("review.compare", json!({"text": "x", "original": outside})),
|
||||
("file.open", json!({"path": outside})),
|
||||
("styles.organizer", json!({"path": s(&base.join("outside/t.docx"))})),
|
||||
("tools.updateStyles", json!({"path": s(&base.join("outside/t.docx"))})),
|
||||
("styles.copyFrom", json!({"path": s(&base.join("outside/t.docx")), "styles": ["Heading 1"]})),
|
||||
] {
|
||||
let (error, text) = run(&mut server, command, params);
|
||||
assert!(error && text.contains("outside the read root"), "{command}: {text}");
|
||||
}
|
||||
assert!(!ok(&mut server, "get_text", json!({})).contains("TOP SECRET"));
|
||||
|
||||
// The same commands work on files inside the root.
|
||||
let (error, text) = run(&mut server, "insert.textFromFile", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
assert!(!error, "{text}");
|
||||
assert!(ok(&mut server, "get_text", json!({})).contains("Inside text"));
|
||||
let (error, text) = run(&mut server, "insert.picture", json!({"path": s(&base.join("in/pic.png"))}));
|
||||
assert!(!error, "{text}");
|
||||
let (error, text) = run(&mut server, "mailings.recipients", json!({"path": s(&base.join("in/people.csv"))}));
|
||||
assert!(!error, "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_exports_go_only_into_the_write_root() {
|
||||
let (mut server, base) = confined("write", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "type_text", json!({"text": "Hello"}));
|
||||
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}));
|
||||
assert!(base.join("out/doc.docx").is_file());
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.pdf"))}));
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("out/page.png"))}));
|
||||
assert!(base.join("out/page.png").is_file());
|
||||
let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/two"))}));
|
||||
assert!(!error, "{text}");
|
||||
// Printing to a PDF (#641) writes a file too.
|
||||
let (error, text) = run(&mut server, "file.printPdf", json!({"path": s(&base.join("out/printed"))}));
|
||||
assert!(!error, "{text}");
|
||||
assert!(base.join("out/printed.pdf").is_file());
|
||||
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "outside the write root");
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("outside/doc.html"))}), "outside the write root");
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("outside/page.png"))}), "outside the write root");
|
||||
refused(
|
||||
&mut server,
|
||||
"save_document",
|
||||
json!({"path": format!("{}{sep}..{sep}outside{sep}up.docx", s(&base.join("out")))}),
|
||||
"outside the write root",
|
||||
);
|
||||
let (error, text) = run(&mut server, "mailings.recipients", json!({"csv": "Name\nAda\n"}));
|
||||
assert!(!error, "{text}");
|
||||
for (command, params) in [
|
||||
("file.saveAs", json!({"path": s(&base.join("outside/x.docx"))})),
|
||||
("file.exportPdf", json!({"path": s(&base.join("outside/x.pdf"))})),
|
||||
("file.printPdf", json!({"path": s(&base.join("outside/printed.pdf"))})),
|
||||
("file.printPdf", json!({"path": format!("{}{sep}..{sep}outside{sep}up", s(&base.join("out")))})),
|
||||
("file.exportPng", json!({"path": s(&base.join("outside/x.png"))})),
|
||||
("file.saveTemplate", json!({"path": s(&base.join("outside/x.dotx"))})),
|
||||
("mailings.finish", json!({"path": s(&base.join("outside/merged.docx"))})),
|
||||
] {
|
||||
let (error, text) = run(&mut server, command, params);
|
||||
assert!(error && text.contains("outside the write root"), "{command}: {text}");
|
||||
}
|
||||
// The document saved inside the write root saves again in place.
|
||||
ok(&mut server, "save_document", json!({}));
|
||||
let mut written: Vec<String> =
|
||||
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
written.sort();
|
||||
assert_eq!(written, ["pic.png", "secret.txt", "t.docx"], "nothing was written outside the roots");
|
||||
assert!(!base.join("in/doc.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let (mut server, base) = confined("read-only", true, false);
|
||||
ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}), "write authority is absent");
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "write authority is absent");
|
||||
assert!(!base.join("out/doc.docx").exists());
|
||||
|
||||
let (mut server, base) = confined("write-only", false, true);
|
||||
refused(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent");
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
#[cfg(unix)]
|
||||
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok();
|
||||
#[cfg(windows)]
|
||||
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let linked = false;
|
||||
// Windows needs Developer Mode or elevation to make links.
|
||||
if !linked {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
return;
|
||||
}
|
||||
refused(&mut server, "open_document", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root");
|
||||
refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root");
|
||||
assert!(!base.join("outside/doc.docx").exists());
|
||||
}
|
||||
+13
-1
@@ -156,6 +156,7 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
bookmarks: std::cell::OnceCell::new(),
|
||||
};
|
||||
if let Some(o) = ex.outline(pages) {
|
||||
pdf.set_outline(o);
|
||||
@@ -522,6 +523,10 @@ struct Exporter<'a> {
|
||||
tag_index: HashMap<(StoryRef, DocPath), usize>,
|
||||
/// Link rectangles on the current page: (x0, y0, x1, y1, target).
|
||||
links: Vec<(f32, f32, f32, f32, String)>,
|
||||
/// Bookmark positions by name (the first of a name wins), gathered once: looking each internal
|
||||
/// link up by walking the whole document made a long document with a linked table of contents
|
||||
/// take seconds to export.
|
||||
bookmarks: std::cell::OnceCell<HashMap<String, wordcraft_doc::Pos>>,
|
||||
}
|
||||
|
||||
/// What the content being drawn is, for tagging.
|
||||
@@ -1210,7 +1215,14 @@ impl Exporter<'_> {
|
||||
/// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which
|
||||
/// in a paragraph split across pages need not be the paragraph's first page.
|
||||
fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> {
|
||||
let (_, pos) = self.doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
let marks = self.bookmarks.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for (n, pos) in self.doc.bookmarks() {
|
||||
m.entry(n).or_insert(pos);
|
||||
}
|
||||
m
|
||||
});
|
||||
let pos = marks.get(name)?.clone();
|
||||
if let Some(c) = self.lay.caret(&pos)
|
||||
&& let Some(out) = self.out_index.get(&c.page)
|
||||
{
|
||||
|
||||
+85
-18
@@ -300,11 +300,44 @@ fn hidden_heading_text_stays_out_of_bookmarks_and_tags() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_fragmented_hidden_headings_export_in_linear_time() {
|
||||
// Headings of n chars in one-char runs: all hidden with bold alternating, and hidden and visible
|
||||
// alternating. Layout and the bookmark/tag titles once scanned every left-out range for every
|
||||
// char, quadratic in n. Machine-independent: the time for 4n against n, linear ~4, quadratic ~16.
|
||||
// char, quadratic in n. Machine-independent: the cost for 8n against n, linear ~8, quadratic ~64.
|
||||
let doc = |n: usize| {
|
||||
let heading = |visible_every_other: bool| {
|
||||
let mut h = Paragraph::with_text(&"abcdefg ".repeat(n / 8), CharProps::default()).styled("Heading1");
|
||||
@@ -321,23 +354,19 @@ fn long_fragmented_hidden_headings_export_in_linear_time() {
|
||||
d
|
||||
};
|
||||
let opts = PdfOptions { compress: false, ..Default::default() };
|
||||
// The fastest of a few runs, after a warm-up, so one slow run on a busy machine counts less.
|
||||
let fastest = |d: &Document| {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
export(d, &opts).unwrap();
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
let n = 25_000;
|
||||
let (small, big) = (doc(n), doc(4 * n));
|
||||
export(&small, &opts).unwrap();
|
||||
let (t1, t4) = (fastest(&small), fastest(&big));
|
||||
let ratio = t4 / t1.max(1e-9);
|
||||
eprintln!("t(n) {t1:.3} s, t(4n) {t4:.3} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "4x the text took {ratio:.1}x the time ({t1:.3} s → {t4:.3} s): not linear");
|
||||
let n = 12_500;
|
||||
let (small, big) = (doc(n), doc(8 * n));
|
||||
let (t1, t8, ratio) = cost_ratio(
|
||||
8,
|
||||
|| {
|
||||
export(&small, &opts).unwrap();
|
||||
},
|
||||
|| {
|
||||
export(&big, &opts).unwrap();
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.3} s, t(8n) {t8:.3} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.3} s → {t8:.3} s): not linear");
|
||||
let pdf = String::from_utf8_lossy(&export(&big, &opts).unwrap()).into_owned();
|
||||
// The all-hidden heading has no bookmark; the other is titled with its visible chars only.
|
||||
assert!(!pdf.contains("abcd"), "hidden heading text in a title");
|
||||
@@ -792,6 +821,7 @@ fn bookmark_destination_is_the_page_of_its_start() {
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
bookmarks: std::cell::OnceCell::new(),
|
||||
};
|
||||
let (page, x, y) = ex.bookmark("target").unwrap();
|
||||
assert_eq!(page, 1, "the bookmark is on the second page");
|
||||
@@ -835,3 +865,40 @@ fn comments_print_in_balloons_beside_scaled_pages() {
|
||||
assert!(squash(&extract_text(&bytes).concat()).contains("On the heading"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A long document whose every paragraph links to a bookmark (a linked table of contents, cross
|
||||
/// references) exports in time proportional to its length: each link looked its bookmark up by
|
||||
/// walking the whole document, so NIH's 67-page protocol template took 10.6 s (0.9 s now).
|
||||
#[test]
|
||||
fn internal_links_export_in_linear_time() {
|
||||
let doc = |n: usize| {
|
||||
let mut d = Document::new();
|
||||
d.body = (0..n)
|
||||
.map(|i| {
|
||||
// Eight links a paragraph, like a dense table of contents or cross-references.
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
for k in 0..8 {
|
||||
let link = CharProps { link: Some(format!("#mark{}", (i * 7 + k) % n)), ..CharProps::default() };
|
||||
p.insert_text(p.len(), &format!("see {k} "), &link).unwrap();
|
||||
}
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: format!("mark{i}") }, &CharProps::default()).unwrap();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
};
|
||||
let fastest = |d: &Document| {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
let bytes = export(d, &PdfOptions::default()).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
let (small, big) = (doc(100), doc(400));
|
||||
let _ = export(&small, &PdfOptions::default());
|
||||
let ratio = fastest(&big) / fastest(&small).max(1e-9);
|
||||
assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)");
|
||||
}
|
||||
|
||||
+276
-45
@@ -11,13 +11,14 @@
|
||||
//! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text
|
||||
//! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB).
|
||||
//!
|
||||
//! Supported: one-character flags (at most 64 distinct), `PFX`/`SFX` with strip, append and
|
||||
//! condition (`.`, letters, `[abc]`, `[^abc]`), cross products, and the suggestion tables `TRY`,
|
||||
//! `MAP` and `REP`. Rejected: compounding, continuation classes, long or numeric flags,
|
||||
//! `NEEDAFFIX`, `FORBIDDENWORD` and the other directives that change which words are correct.
|
||||
//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`),
|
||||
//! `PFX`/`SFX` with strip, append and condition (`.`, letters, `[abc]`, `[^abc]`), cross
|
||||
//! products, continuation classes on suffixes (at most two suffixes in a row, as in Hunspell),
|
||||
//! `FORBIDDENWORD`, and the suggestion tables `TRY`, `MAP` and `REP`. Rejected: compounding,
|
||||
//! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct.
|
||||
//!
|
||||
//! Lookups never panic and never allocate per probe: stems sit in one string, found through an
|
||||
//! open-addressing hash table.
|
||||
//! Lookups never panic: stems sit in one string, found through an open-addressing hash table, and
|
||||
//! a probe allocates only small scratch strings for the candidate roots it tries.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hasher;
|
||||
@@ -44,7 +45,6 @@ const UNSUPPORTED: &[&str] = &[
|
||||
"COMPOUNDMIN",
|
||||
"COMPOUNDPERMITFLAG",
|
||||
"COMPOUNDRULE",
|
||||
"FORBIDDENWORD",
|
||||
"FULLSTRIP",
|
||||
"ICONV",
|
||||
"IGNORE",
|
||||
@@ -114,15 +114,25 @@ fn parse_cond(text: &str) -> Result<Box<[Cond]>, String> {
|
||||
Ok(out.into_boxed_slice())
|
||||
}
|
||||
|
||||
/// How many suffixes a word may carry in a row through continuation classes (`work` +
|
||||
/// `able/B` + `s` → `workables` is two). Hunspell strips at most two suffixes, so a third
|
||||
/// chained suffix is not a word; the bound also keeps hostile input from walking deep recursions.
|
||||
const MAX_CHAIN: u8 = 2;
|
||||
/// How many suffixes may sit under the outermost one: the depth `form_has_flag` walks.
|
||||
const INNER_CHAIN: u8 = MAX_CHAIN - 1;
|
||||
|
||||
/// One prefix or suffix rule.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Rule {
|
||||
/// Bit of the rule's flag in the stems' flag masks.
|
||||
bit: u64,
|
||||
bit: u128,
|
||||
flag: char,
|
||||
cross: bool,
|
||||
strip: Box<str>,
|
||||
add: Box<str>,
|
||||
/// Continuation classes (`SFX A 0 able/B`): flags the derived form carries, licensing
|
||||
/// further affixes. Chained application is bounded by [`MAX_CHAIN`].
|
||||
cont: u128,
|
||||
cond: Box<[Cond]>,
|
||||
cond_text: Box<str>,
|
||||
}
|
||||
@@ -159,7 +169,9 @@ pub struct AffixDict {
|
||||
/// Per stem: index into `sets`.
|
||||
flags: Vec<u16>,
|
||||
/// Distinct flag masks.
|
||||
sets: Vec<u64>,
|
||||
sets: Vec<u128>,
|
||||
/// The `FORBIDDENWORD` flag's bit; stems carrying it are not words (0: none declared).
|
||||
forbidden: u128,
|
||||
/// Open-addressing hash table of stem indices (`EMPTY` = free).
|
||||
table: Vec<u32>,
|
||||
/// Flag char of each bit.
|
||||
@@ -232,13 +244,18 @@ pub fn capitalize(w: &str) -> String {
|
||||
/// Parsed rules and tables, shared by the Hunspell reader and the compact format.
|
||||
#[derive(Default)]
|
||||
struct Rules {
|
||||
prefixes: Vec<(char, bool, String, String, String)>,
|
||||
suffixes: Vec<(char, bool, String, String, String)>,
|
||||
prefixes: Vec<RawRule>,
|
||||
suffixes: Vec<RawRule>,
|
||||
try_chars: String,
|
||||
map: Vec<Vec<char>>,
|
||||
rep: Vec<(String, String)>,
|
||||
/// The `FORBIDDENWORD` flag: stems carrying it are listed but never correct.
|
||||
forbidden: Option<char>,
|
||||
}
|
||||
|
||||
/// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`.
|
||||
type RawRule = (char, bool, String, String, String, String);
|
||||
|
||||
/// `0` means empty in Hunspell's strip and append fields.
|
||||
fn zero_empty(s: &str) -> String {
|
||||
if s == "0" { String::new() } else { s.to_string() }
|
||||
@@ -260,7 +277,20 @@ fn parse_aff(aff: &str) -> Result<Rules, String> {
|
||||
match dir {
|
||||
"SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "KEY" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS"
|
||||
| "SUGSWITHDOTS" | "NOSUGGEST" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {}
|
||||
"FLAG" => return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))),
|
||||
"FLAG" => {
|
||||
// Per-character flag modes (`FLAG UTF-8`, as VERO's pt_BR dictionary uses) work
|
||||
// with the one-character parsing here; only the multi-character encodings differ.
|
||||
if !matches!(at(1), "UTF-8" | "DEFAULT" | "utf-8" | "default" | "UTF8" | "") {
|
||||
return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1)));
|
||||
}
|
||||
}
|
||||
"FORBIDDENWORD" => {
|
||||
let mut it = at(1).chars();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(c), None) if !c.is_whitespace() => r.forbidden = Some(c),
|
||||
_ => return Err(format!("line {}: FORBIDDENWORD needs one flag character", n + 1)),
|
||||
}
|
||||
}
|
||||
"TRY" => r.try_chars = at(1).to_string(),
|
||||
"MAP" => {
|
||||
if !map_header && at(1).parse::<usize>().is_ok() {
|
||||
@@ -290,14 +320,15 @@ fn parse_aff(aff: &str) -> Result<Rules, String> {
|
||||
if f.len() < 4 {
|
||||
return Err(format!("line {}: short affix rule", n + 1));
|
||||
}
|
||||
let add = at(3);
|
||||
if add.contains('/') {
|
||||
return Err(format!("line {}: continuation classes are not supported", n + 1));
|
||||
}
|
||||
// `able/B`: the appended text and its continuation classes.
|
||||
let (add, cont) = match at(3).split_once('/') {
|
||||
Some((a, c)) => (a, c),
|
||||
None => (at(3), ""),
|
||||
};
|
||||
let cond = if f.len() >= 5 { at(4) } else { "." };
|
||||
parse_cond(cond).map_err(|e| format!("line {}: {e}", n + 1))?;
|
||||
let c = cross.get(&(suffix, flag)).copied().unwrap_or(false);
|
||||
let rule = (flag, c, zero_empty(at(2)), zero_empty(add), cond.to_string());
|
||||
let rule: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), cont.to_string(), cond.to_string());
|
||||
if suffix {
|
||||
r.suffixes.push(rule);
|
||||
} else {
|
||||
@@ -315,6 +346,10 @@ impl AffixDict {
|
||||
/// Build from Hunspell `.aff` and `.dic` text (decoded to UTF-8; the `.dic` count line and
|
||||
/// morphological fields are ignored). `comments` (provenance, licence) are kept with the data.
|
||||
pub fn from_hunspell(aff: &str, dic: &str, comments: &[&str]) -> Result<AffixDict, String> {
|
||||
// Some dictionaries open with a UTF-8 BOM; it must not glue itself to the first
|
||||
// directive or turn the `.dic` count line into a stem.
|
||||
let aff = aff.strip_prefix('\u{feff}').unwrap_or(aff);
|
||||
let dic = dic.strip_prefix('\u{feff}').unwrap_or(dic);
|
||||
let rules = parse_aff(aff)?;
|
||||
let mut stems: Vec<(String, String)> = Vec::new();
|
||||
for (n, line) in dic.lines().enumerate() {
|
||||
@@ -353,16 +388,18 @@ impl AffixDict {
|
||||
flag_chars.extend(stems.iter().flat_map(|s| s.1.chars()));
|
||||
flag_chars.sort_unstable();
|
||||
flag_chars.dedup();
|
||||
if flag_chars.len() > 64 {
|
||||
return Err(format!("{} distinct flags; at most 64 are supported", flag_chars.len()));
|
||||
if flag_chars.len() > 128 {
|
||||
return Err(format!("{} distinct flags; at most 128 are supported", flag_chars.len()));
|
||||
}
|
||||
let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u64 << i);
|
||||
let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u128 << i);
|
||||
let forbidden = rules.forbidden.map_or(0, bit_of);
|
||||
// Stems sorted by bytes; homonyms (the same word twice) are merged.
|
||||
stems.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||
let mut d = AffixDict {
|
||||
words: String::with_capacity(stems.iter().map(|s| s.0.len()).sum()),
|
||||
offs: Vec::with_capacity(stems.len() + 1),
|
||||
flag_chars: flag_chars.clone(),
|
||||
forbidden,
|
||||
try_chars: rules.try_chars,
|
||||
map: rules.map,
|
||||
rep: rules.rep,
|
||||
@@ -370,11 +407,11 @@ impl AffixDict {
|
||||
..AffixDict::default()
|
||||
};
|
||||
d.offs.push(0);
|
||||
let mut set_ids: HashMap<u64, u16> = HashMap::new();
|
||||
let mut set_ids: HashMap<u128, u16> = HashMap::new();
|
||||
let mut i = 0;
|
||||
while i < stems.len() {
|
||||
let Some((word, _)) = stems.get(i) else { break };
|
||||
let mut mask = 0u64;
|
||||
let mut mask = 0u128;
|
||||
let mut j = i;
|
||||
while let Some((w, f)) = stems.get(j)
|
||||
&& w == word
|
||||
@@ -392,8 +429,9 @@ impl AffixDict {
|
||||
d.flags.push(id);
|
||||
i = j;
|
||||
}
|
||||
let mk = |(flag, cross, strip, add, cond): (char, bool, String, String, String)| -> Result<Rule, String> {
|
||||
Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cond: parse_cond(&cond)?, cond_text: cond.into() })
|
||||
let mk = |(flag, cross, strip, add, cont, cond): RawRule| -> Result<Rule, String> {
|
||||
let cont = cont.chars().fold(0u128, |m, c| m | bit_of(c));
|
||||
Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cont, cond: parse_cond(&cond)?, cond_text: cond.into() })
|
||||
};
|
||||
d.prefixes = rules.prefixes.into_iter().map(mk).collect::<Result<_, _>>()?;
|
||||
d.suffixes = rules.suffixes.into_iter().map(mk).collect::<Result<_, _>>()?;
|
||||
@@ -451,6 +489,21 @@ impl AffixDict {
|
||||
self.words.get(a..b)
|
||||
}
|
||||
|
||||
/// The flag characters of `mask`, in flag order (how the compact format stores flags).
|
||||
fn flag_chars_for(&self, mask: u128) -> String {
|
||||
self.flag_chars.iter().enumerate().filter(|&(i, _)| mask & (1u128 << i) != 0).map(|(_, c)| c).collect()
|
||||
}
|
||||
|
||||
/// The character of `mask` when it holds exactly one flag.
|
||||
fn flag_char(&self, mask: u128) -> Option<char> {
|
||||
let s = self.flag_chars_for(mask);
|
||||
let mut it = s.chars();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(c), None) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stems, in byte order.
|
||||
pub fn stems(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
(0..self.len()).filter_map(|i| self.word(i))
|
||||
@@ -475,7 +528,7 @@ impl AffixDict {
|
||||
}
|
||||
|
||||
/// Flag mask of a stem, if `w` is one.
|
||||
fn stem_flags(&self, w: &str) -> Option<u64> {
|
||||
fn stem_flags(&self, w: &str) -> Option<u128> {
|
||||
if self.table.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -488,13 +541,44 @@ impl AffixDict {
|
||||
}
|
||||
if self.word(t as usize) == Some(w) {
|
||||
let id = *self.flags.get(t as usize)?;
|
||||
return self.sets.get(usize::from(id)).copied();
|
||||
let mask = self.sets.get(usize::from(id)).copied();
|
||||
// A stem carrying FORBIDDENWORD is listed but is not a word: no direct hit,
|
||||
// and (this is the only stem lookup) no affixed form is built on it either.
|
||||
return mask.filter(|m| m & self.forbidden == 0);
|
||||
}
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Does `w` carry flag `bit` — as a stem, or as the form of a suffix rule whose
|
||||
/// continuation classes include `bit` (chained suffixation, depth-bounded)?
|
||||
fn form_has_flag(&self, w: &str, bit: u128, depth: u8) -> bool {
|
||||
if self.stem_flags(w).is_some_and(|f| f & bit != 0) {
|
||||
return true;
|
||||
}
|
||||
if depth == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut root = String::with_capacity(w.len() + 8);
|
||||
let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0));
|
||||
for b in cuts.take(self.max_sfx_chars + 1) {
|
||||
let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue };
|
||||
let Some(groups) = self.sfx_index.get(add) else { continue };
|
||||
for g in groups {
|
||||
root.clear();
|
||||
root.push_str(base);
|
||||
root.push_str(&g.strip);
|
||||
for r in g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) {
|
||||
if r.cont & bit != 0 && r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, depth - 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `w` exactly (case and all) a stem or an affixed form of one?
|
||||
pub fn contains(&self, w: &str) -> bool {
|
||||
if w.is_empty() || w.len() > MAX_WORD_BYTES {
|
||||
@@ -518,6 +602,9 @@ impl AffixDict {
|
||||
if self.stem_flags(&root).is_some_and(|f| f & r.bit != 0) {
|
||||
return true;
|
||||
}
|
||||
if self.form_has_flag(&root, r.bit, INNER_CHAIN) {
|
||||
return true;
|
||||
}
|
||||
if r.cross && self.suffixed(&root, r.bit) {
|
||||
return true;
|
||||
}
|
||||
@@ -527,7 +614,7 @@ impl AffixDict {
|
||||
|
||||
/// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must
|
||||
/// carry those flags too and the suffix must allow cross products.
|
||||
fn suffixed(&self, w: &str, need: u64) -> bool {
|
||||
fn suffixed(&self, w: &str, need: u128) -> bool {
|
||||
let mut root = String::with_capacity(w.len() + 8);
|
||||
// Each split point from the end: the appended string is `w[b..]`, at most the longest
|
||||
// suffix, and something must stay before it.
|
||||
@@ -539,16 +626,18 @@ impl AffixDict {
|
||||
root.clear();
|
||||
root.push_str(base);
|
||||
root.push_str(&g.strip);
|
||||
let Some(f) = self.stem_flags(&root) else { continue };
|
||||
if f & need != need {
|
||||
continue;
|
||||
let rules = || g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize));
|
||||
// The stem path: `root` is a stem carrying the rule's flag (and the prefix's,
|
||||
// when a cross product is being checked).
|
||||
if let Some(f) = self.stem_flags(&root)
|
||||
&& f & need == need
|
||||
&& rules().any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let ok = g
|
||||
.rules
|
||||
.iter()
|
||||
.filter_map(|&i| self.suffixes.get(i as usize))
|
||||
.any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true));
|
||||
if ok {
|
||||
// Continuation classes: `root` need not be a stem carrying this rule's flag —
|
||||
// it may be a form that gained the flag from another rule's continuation.
|
||||
if need == 0 && rules().any(|r| r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, INNER_CHAIN)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -578,6 +667,8 @@ impl AffixDict {
|
||||
let Some(f) = self.stem_flags(stem) else { return Vec::new() };
|
||||
let mut out = vec![stem.to_string()];
|
||||
let mut cross_sfx = Vec::new();
|
||||
// Suffix forms queued with the continuation classes they carry, for chained expansion.
|
||||
let mut chain: Vec<(String, u128)> = Vec::new();
|
||||
for r in &self.suffixes {
|
||||
if f & r.bit == 0 || !r.cond_matches(stem, true) {
|
||||
continue;
|
||||
@@ -587,8 +678,32 @@ impl AffixDict {
|
||||
if r.cross {
|
||||
cross_sfx.push(form.clone());
|
||||
}
|
||||
if r.cont != 0 {
|
||||
chain.push((form.clone(), r.cont));
|
||||
}
|
||||
out.push(form);
|
||||
}
|
||||
// Continuation classes chain suffixes (`workable/B` → `workables`), up to [`MAX_CHAIN`]
|
||||
// suffixes in all — the same bound the checker walks.
|
||||
for _ in 0..INNER_CHAIN {
|
||||
let level = std::mem::take(&mut chain);
|
||||
if level.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (form, flags) in level {
|
||||
for r in &self.suffixes {
|
||||
if flags & r.bit == 0 || !r.cond_matches(&form, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(base) = form.strip_suffix(&*r.strip) else { continue };
|
||||
let next = format!("{base}{}", r.add);
|
||||
if r.cont != 0 {
|
||||
chain.push((next.clone(), r.cont));
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in &self.prefixes {
|
||||
if f & r.bit == 0 || !r.cond_matches(stem, false) {
|
||||
continue;
|
||||
@@ -648,11 +763,23 @@ impl AffixDict {
|
||||
for (a, b) in &self.rep {
|
||||
out.push_str(&format!("REP {} {}\n", a.replace(' ', "_"), b.replace(' ', "_")));
|
||||
}
|
||||
if self.forbidden != 0
|
||||
&& let Some(c) = self.flag_char(self.forbidden)
|
||||
{
|
||||
out.push_str(&format!("FORBIDDENWORD {c}\n"));
|
||||
}
|
||||
let z = |s: &str| if s.is_empty() { "0".to_string() } else { s.to_string() };
|
||||
for (kind, rules) in [("PFX", &self.prefixes), ("SFX", &self.suffixes)] {
|
||||
for r in rules {
|
||||
let y = if r.cross { "Y" } else { "N" };
|
||||
out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), z(&r.add), r.cond_text));
|
||||
// Continuation classes ride on the appended field, as in the `.aff` source.
|
||||
let mut add = z(&r.add);
|
||||
let cont = self.flag_chars_for(r.cont);
|
||||
if !cont.is_empty() {
|
||||
add.push('/');
|
||||
add.push_str(&cont);
|
||||
}
|
||||
out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), add, r.cond_text));
|
||||
}
|
||||
}
|
||||
out.push_str("WORDS\n");
|
||||
@@ -667,12 +794,7 @@ impl AffixDict {
|
||||
let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or(0);
|
||||
if mask != 0 {
|
||||
raw.push(b'/');
|
||||
for (bit, c) in self.flag_chars.iter().enumerate() {
|
||||
if mask & (1 << bit) != 0 {
|
||||
let mut buf = [0u8; 4];
|
||||
raw.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
}
|
||||
raw.extend_from_slice(self.flag_chars_for(mask).as_bytes());
|
||||
}
|
||||
raw.push(b'\n');
|
||||
prev = b;
|
||||
@@ -703,13 +825,25 @@ impl AffixDict {
|
||||
"TRY" => rules.try_chars = at(1).to_string(),
|
||||
"MAP" => rules.map.push(at(1).chars().collect()),
|
||||
"REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))),
|
||||
"FORBIDDENWORD" => {
|
||||
let mut it = at(1).chars();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(c), None) if !c.is_whitespace() => rules.forbidden = Some(c),
|
||||
_ => return Err(format!("affix dictionary: bad FORBIDDENWORD in `{line}`")),
|
||||
}
|
||||
}
|
||||
kind @ ("PFX" | "SFX") => {
|
||||
let mut fc = at(1).chars();
|
||||
let (Some(flag), None) = (fc.next(), fc.next()) else { return Err(format!("affix dictionary: bad flag in `{line}`")) };
|
||||
if f.len() != 6 {
|
||||
return Err(format!("affix dictionary: bad rule `{line}`"));
|
||||
}
|
||||
let rule = (flag, at(2) == "Y", zero_empty(at(3)), zero_empty(at(4)), at(5).to_string());
|
||||
// Continuation classes ride on the appended field, as in the `.aff` source.
|
||||
let (add, cont) = match at(4).split_once('/') {
|
||||
Some((a, c)) => (zero_empty(a), c.to_string()),
|
||||
None => (zero_empty(at(4)), String::new()),
|
||||
};
|
||||
let rule: RawRule = (flag, at(2) == "Y", zero_empty(at(3)), add, cont, at(5).to_string());
|
||||
if kind == "SFX" {
|
||||
rules.suffixes.push(rule);
|
||||
} else {
|
||||
@@ -817,11 +951,108 @@ mod tests {
|
||||
assert!(!back.contains("kotą"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_utf8_and_default_modes_are_accepted() {
|
||||
// `FLAG UTF-8` (VERO pt_BR) and `FLAG DEFAULT` are per-character flag modes: the same
|
||||
// mode the reader already assumes. Only `long` and `num` stay unsupported.
|
||||
for mode in ["UTF-8", "DEFAULT"] {
|
||||
let aff = format!("SET UTF-8\nFLAG {mode}\nSFX a Y 1\nSFX a 0 y [^y]\n");
|
||||
let d = AffixDict::from_hunspell(&aff, "1\nkot/a\n", &[]).unwrap();
|
||||
assert!(d.contains("kot") && d.contains("koty"), "{mode}");
|
||||
}
|
||||
assert!(AffixDict::from_hunspell("FLAG UTF-8\n", "0\n", &[]).is_ok(), "FLAG alone is harmless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_64_distinct_flags_load() {
|
||||
// VERO pt_BR uses 106 distinct flags (FLAG UTF-8). Build one with every basic Latin
|
||||
// letter, the digits and eight accented letters: 70 flags, 64 < n <= 128.
|
||||
let mut flags = String::new();
|
||||
let mut aff = String::from("SET UTF-8\nFLAG UTF-8\n");
|
||||
let chars: Vec<char> = ('a'..='z').chain('A'..='Z').chain('0'..='9').chain(['á', 'é', 'í', 'ó', 'ú', 'ç', 'ã', 'õ']).collect();
|
||||
assert_eq!(chars.len(), 70);
|
||||
for c in chars {
|
||||
aff.push_str(&format!("SFX {c} N 1\nSFX {c} 0 {c} .\n"));
|
||||
flags.push(c);
|
||||
}
|
||||
let d = AffixDict::from_hunspell(&aff, &format!("1\nverbo/{flags}\n"), &[]).unwrap();
|
||||
assert!(d.contains("verbo"));
|
||||
assert!(d.contains("verboa") && d.contains("verboz") && d.contains("verbo9"));
|
||||
assert!(d.contains("verboç") && d.contains("verboã"), "multi-byte UTF-8 flags");
|
||||
assert!(!d.contains("verboß"), "ß is not a flag and not a stem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_words_are_rejected() {
|
||||
// VERO pt_BR marks spellings it must reject (flag ý under FLAG UTF-8) as stems carrying
|
||||
// FORBIDDENWORD: listed, but never correct and never inflected.
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX a Y 1\nSFX a 0 y [^y]\n";
|
||||
let d = AffixDict::from_hunspell(aff, "2\nkot/a\nimundo/ý\n", &[]).unwrap();
|
||||
assert!(d.check("kot") && d.check("koty") && d.check("Kot") && d.check("KOT"));
|
||||
assert!(!d.check("imundo") && !d.check("Imundo") && !d.check("IMUNDO"));
|
||||
assert!(!d.contains("imundoy"), "a forbidden stem must not inflect");
|
||||
assert!(d.forms("imundo").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_classes_chain_suffixes() {
|
||||
// `SFX A 0 able/B`: the form derived by A carries flag B, so a suffix of class B may
|
||||
// follow (workable → workables). The bare stem has A only, so `works` stays wrong.
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n";
|
||||
let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap();
|
||||
assert!(d.contains("work") && d.contains("workable") && d.contains("workables"));
|
||||
assert!(!d.contains("works"), "the stem has A, not B");
|
||||
assert!(!d.contains("workabless"), "B declares no continuation");
|
||||
assert!(d.check("Workable") && d.check("WORKABLES"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_chains_stop_at_two_suffixes() {
|
||||
// Hunspell strips at most two suffixes: work + able/B + s/C is a word, a third (C) is not.
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s/C .\nSFX C N 1\nSFX C 0 x .\n";
|
||||
let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap();
|
||||
assert!(d.contains("workable") && d.contains("workables"));
|
||||
assert!(!d.contains("workablesx"), "three chained suffixes exceed the limit");
|
||||
assert!(!d.forms("work").iter().any(|f| f == "workablesx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_include_continuation_chains() {
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n";
|
||||
let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap();
|
||||
let mut f = d.forms("work");
|
||||
f.sort();
|
||||
f.dedup();
|
||||
assert_eq!(f, ["work", "workable", "workables"]);
|
||||
for form in d.forms("work") {
|
||||
assert!(d.contains(&form), "every listed form is accepted: {form}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_format_round_trips_continuations_and_forbidden() {
|
||||
let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n";
|
||||
let d = AffixDict::from_hunspell(aff, "2\nwork/A\nimundo/ý\n", &[]).unwrap();
|
||||
let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();
|
||||
assert!(back.contains("work") && back.contains("workable") && back.contains("workables"));
|
||||
assert!(!back.contains("works"), "the stem has A, not B, after a round trip");
|
||||
assert!(!back.check("imundo") && !back.contains("imundoy"), "forbidden survives a round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dic_bom_glued_to_the_count_line_is_skipped() {
|
||||
// VERO's .dic opens with a UTF-8 BOM glued to its count line; it must not become a stem.
|
||||
let aff = "SET UTF-8\nSFX a Y 1\nSFX a 0 y [^y]\n";
|
||||
let d = AffixDict::from_hunspell(aff, "\u{feff}1\nkot/a\n", &[]).unwrap();
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(d.contains("kot") && d.contains("koty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_broken_input_is_an_error() {
|
||||
assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("FLAG num\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("COMPOUNDFLAG X\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y/b .\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y [ab\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_bytes(b"not deflate").is_err());
|
||||
let ok = dict().to_bytes();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Bulgarian hyphenation by rule ([`ProofLang::Bg`](crate::ProofLang::Bg)).
|
||||
//!
|
||||
//! Bulgarian spelling splits words at syllables, and its rules are regular enough to apply
|
||||
//! directly, so no pattern file or word list is needed (and none is borrowed):
|
||||
//! - two vowels next to each other may be split (`на-у-ка`);
|
||||
//! - one consonant between vowels goes with the next syllable (`ма-ма`, `ро-за`);
|
||||
//! - of two or more consonants between vowels, the first stays with the syllable before and the
|
||||
//! rest go with the next (`сес-тра`, `въз-дух`, `ком-пю-тър`) — the spelling rules allow any
|
||||
//! split of a consonant group, and this is the usual one — except that a consonant followed by
|
||||
//! `р` or `л` starts the next syllable together with it (`до-бре`, `ко-пре`, `та-бли-ца`);
|
||||
//! - `й` before a consonant stays with the vowel before it (`строй-ка`, `май-ка`);
|
||||
//! - `ьо` is never split, and the consonant before `ь` goes with it (`шо-фьор`);
|
||||
//! - `дж` and `дз`, one sound each, are never split (`хо-джа`).
|
||||
//!
|
||||
//! A single letter left at the end or start of a line is ruled out by the paragraph's
|
||||
//! [`Limits`](crate::hyphen::Limits) (`Бъл-га-рия`, not `Бъл-га-ри-я`).
|
||||
|
||||
fn is_vowel(c: char) -> bool {
|
||||
matches!(c, 'а' | 'е' | 'и' | 'о' | 'у' | 'ъ' | 'ю' | 'я' | 'ѝ')
|
||||
}
|
||||
|
||||
/// Consonants that may start a syllable before `р` or `л` (`бр`, `пл`, `тр`, `гл` …).
|
||||
fn is_obstruent(c: char) -> bool {
|
||||
matches!(c, 'б' | 'в' | 'г' | 'д' | 'з' | 'к' | 'п' | 'с' | 'т' | 'ф' | 'х')
|
||||
}
|
||||
|
||||
/// A lowercase Bulgarian letter (the words [`points`] splits).
|
||||
pub(crate) fn is_letter(c: char) -> bool {
|
||||
matches!(c, 'а'..='я' | 'ѝ')
|
||||
}
|
||||
|
||||
/// Break points of one lowercase Bulgarian word (char indices; a hyphen goes before that char).
|
||||
/// Empty for anything that is not entirely Bulgarian letters.
|
||||
pub fn points(word: &[char]) -> Vec<usize> {
|
||||
if word.len() < 2 || !word.iter().all(|&c| is_letter(c)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let vowels: Vec<usize> = (0..word.len()).filter(|&i| is_vowel(word[i])).collect();
|
||||
let mut out = Vec::new();
|
||||
for pair in vowels.windows(2) {
|
||||
let (v1, v2) = (pair[0], pair[1]);
|
||||
let mut pt = match v2 - v1 - 1 {
|
||||
0 | 1 => v1 + 1,
|
||||
_ => v1 + 2,
|
||||
};
|
||||
// A consonant with `р` or `л` after it starts a syllable together with it.
|
||||
if v2 - v1 > 2 && matches!(word[v2 - 1], 'р' | 'л') && is_obstruent(word[v2 - 2]) {
|
||||
pt = v2 - 2;
|
||||
}
|
||||
// `дж`, `дз`: one sound, so both go to the next syllable.
|
||||
if pt > v1 + 1 && word[pt - 1] == 'д' && matches!(word[pt], 'ж' | 'з') {
|
||||
pt -= 1;
|
||||
}
|
||||
// `ь` keeps the consonant before it (`фьо`).
|
||||
if pt < v2 && word[pt] == 'ь' && pt > v1 + 1 {
|
||||
pt -= 1;
|
||||
}
|
||||
if pt > v1 && pt <= v2 && word[pt - 1] != 'ь' {
|
||||
out.push(pt);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn split(w: &str) -> String {
|
||||
let chars: Vec<char> = w.chars().collect();
|
||||
let pts = points(&chars);
|
||||
let mut s = String::new();
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if pts.contains(&i) {
|
||||
s.push('-');
|
||||
}
|
||||
s.push(*c);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syllables_follow_the_bulgarian_rules() {
|
||||
for (word, expected) in [
|
||||
("мама", "ма-ма"),
|
||||
("наука", "на-у-ка"),
|
||||
("сестра", "сес-тра"),
|
||||
("въздух", "въз-дух"),
|
||||
("компютър", "ком-пю-тър"),
|
||||
("стройка", "строй-ка"),
|
||||
("майка", "май-ка"),
|
||||
("шофьор", "шо-фьор"),
|
||||
("ходжа", "хо-джа"),
|
||||
("здравей", "здра-вей"),
|
||||
("плевен", "пле-вен"),
|
||||
("българия", "бъл-га-ри-я"),
|
||||
("сричкопренасяне", "срич-ко-пре-на-ся-не"),
|
||||
("добре", "до-бре"),
|
||||
("таблица", "та-бли-ца"),
|
||||
("отговор", "от-го-вор"),
|
||||
] {
|
||||
assert_eq!(split(word), expected, "{word}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bulgarian_letters_are_left_alone() {
|
||||
assert!(points(&"word".chars().collect::<Vec<_>>()).is_empty());
|
||||
assert!(points(&"съобщение1".chars().collect::<Vec<_>>()).is_empty());
|
||||
assert!(points(&"объём".chars().collect::<Vec<_>>()).is_empty(), "Russian ё is not a Bulgarian letter");
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,20 @@ pub fn set_dictionary_dir(dir: Option<PathBuf>) {
|
||||
*EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
||||
}
|
||||
|
||||
/// Directories that may hold Hunspell dictionaries, most specific first.
|
||||
pub fn search_dirs() -> Vec<PathBuf> {
|
||||
/// The LibreOffice extension directories a Hunspell dictionary name may live in: LibreOffice's
|
||||
/// IDs are per dictionary — Polish is `dict-pl`, Brazilian Portuguese `dict-pt-BR` — so both
|
||||
/// the primary-language folder and the dashed full name are tried.
|
||||
fn lo_ext_dirs(name: &str) -> Vec<String> {
|
||||
let primary = name.split(['-', '_']).next().unwrap_or(name);
|
||||
let dashed = name.replace('_', "-");
|
||||
let first = format!("dict-{primary}");
|
||||
let full = format!("dict-{dashed}");
|
||||
if full == first { vec![first] } else { vec![first, full] }
|
||||
}
|
||||
|
||||
/// Directories that may hold Hunspell dictionaries, most specific first. `lo_exts`, when a
|
||||
/// language is being searched for, adds LibreOffice's extension folders for it (`dict-pt-BR`).
|
||||
pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
dirs.push(d);
|
||||
@@ -38,6 +50,9 @@ pub fn search_dirs() -> Vec<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("WORDCRAFT_DICTIONARIES").filter(|v| !v.is_empty()) {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
// LibreOffice keeps each language under `…/share/extensions/<dict-xx>`.
|
||||
let lo_dirs =
|
||||
|prefix: &str| -> Vec<PathBuf> { lo_exts.map_or_else(Vec::new, |exts| exts.iter().map(|e| PathBuf::from(prefix).join(e)).collect()) };
|
||||
let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
if cfg!(windows) {
|
||||
for var in ["LOCALAPPDATA", "APPDATA"] {
|
||||
@@ -48,7 +63,7 @@ pub fn search_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
for var in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) {
|
||||
dirs.push(base.join("LibreOffice").join("share").join("extensions").join("dict-pl"));
|
||||
dirs.extend(lo_dirs(&base.join("LibreOffice").join("share").join("extensions").to_string_lossy()));
|
||||
}
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -58,30 +73,26 @@ pub fn search_dirs() -> Vec<PathBuf> {
|
||||
dirs.push(PathBuf::from("/Library/Spelling"));
|
||||
dirs.push(PathBuf::from("/opt/homebrew/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/Applications/LibreOffice.app/Contents/Resources/extensions/dict-pl"));
|
||||
dirs.extend(lo_dirs("/Applications/LibreOffice.app/Contents/Resources/extensions"));
|
||||
} else {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".local/share/hunspell"));
|
||||
}
|
||||
for d in [
|
||||
"/usr/share/hunspell",
|
||||
"/usr/share/myspell",
|
||||
"/usr/share/myspell/dicts",
|
||||
"/usr/local/share/hunspell",
|
||||
"/usr/local/share/myspell",
|
||||
"/usr/lib/libreoffice/share/extensions/dict-pl",
|
||||
"/opt/libreoffice/share/extensions/dict-pl",
|
||||
] {
|
||||
for d in ["/usr/share/hunspell", "/usr/share/myspell", "/usr/share/myspell/dicts", "/usr/local/share/hunspell", "/usr/local/share/myspell"] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
for d in ["/usr/lib/libreoffice/share/extensions", "/opt/libreoffice/share/extensions"] {
|
||||
dirs.extend(lo_dirs(d));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The first `<name>.aff`/`<name>.dic` pair found for any of `names` (`pl_PL`, `pl`), searching
|
||||
/// [`search_dirs`].
|
||||
/// [`search_dirs`] (including LibreOffice's extension folder for the first name).
|
||||
pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
search_dirs().into_iter().find_map(|dir| find_in(&dir, names))
|
||||
let lo = names.first().copied().map(lo_ext_dirs);
|
||||
search_dirs(lo.as_deref()).into_iter().find_map(|dir| find_in(&dir, names))
|
||||
}
|
||||
|
||||
/// The first `<name>.aff`/`<name>.dic` pair for any of `names` in `dir`.
|
||||
@@ -146,6 +157,18 @@ mod tests {
|
||||
assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libreoffice_extension_dirs_follow_the_language() {
|
||||
// LibreOffice's IDs are per dictionary: Polish is `dict-pl`, Brazilian Portuguese
|
||||
// `dict-pt-BR`, so both the primary-language folder and the dashed full name are tried.
|
||||
assert_eq!(super::lo_ext_dirs("pl_PL"), ["dict-pl", "dict-pl-PL"]);
|
||||
assert_eq!(super::lo_ext_dirs("pt_BR"), ["dict-pt", "dict-pt-BR"]);
|
||||
assert_eq!(super::lo_ext_dirs("de-DE"), ["dict-de", "dict-de-DE"]);
|
||||
let dirs = search_dirs(Some(&super::lo_ext_dirs("pt_BR")));
|
||||
assert!(dirs.iter().any(|d| d.ends_with("dict-pt-BR")), "{dirs:?}");
|
||||
assert!(search_dirs(None).iter().all(|d| !d.to_string_lossy().contains("dict-")), "no language: no LibreOffice guess");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_and_loads_a_pair_in_a_directory() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-{}", std::process::id()));
|
||||
|
||||
+120
-16
@@ -13,6 +13,8 @@
|
||||
//! Kołodziejska, Bogusław Jackowski and Marek Ryćko, MIT licence option), then a Polish syllable
|
||||
//! heuristic for words with letters outside the Polish alphabet (`é`, `ü`…).
|
||||
//!
|
||||
//! **Bulgarian**: the Bulgarian syllable rules ([`crate::bg`]); no pattern file or word list.
|
||||
//!
|
||||
//! Then the paragraph's limits ([`Limits`]) are applied.
|
||||
|
||||
pub use crate::dict;
|
||||
@@ -29,6 +31,8 @@ static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic"
|
||||
static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat");
|
||||
/// hyph-utf8's `hyph-pl.tex`, unmodified (its licence header is in the file).
|
||||
static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat");
|
||||
/// hyph-utf8's `hyph-pt.tex`, unmodified (its BSD 3-clause header is in the file).
|
||||
static PT_PAT: &str = include_str!("../../../assets/hyphenation/pt.pat");
|
||||
|
||||
impl Dictionary {
|
||||
/// The bundled US English dictionary (decoded on first use).
|
||||
@@ -58,6 +62,12 @@ impl Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(PL_PAT))
|
||||
}
|
||||
|
||||
/// The bundled Portuguese patterns and exception words (parsed on first use).
|
||||
pub fn pt() -> &'static Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(PT_PAT))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation limits from the paragraph's Hyphenation settings.
|
||||
@@ -81,6 +91,8 @@ pub enum Source {
|
||||
Dictionary,
|
||||
Patterns,
|
||||
Heuristic,
|
||||
/// A language's syllable rules (Bulgarian, [`crate::bg`]).
|
||||
Rules,
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -101,6 +113,14 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
if lang == ProofLang::Pl {
|
||||
return core_breaks_pl(core);
|
||||
}
|
||||
// Bulgarian words follow the Bulgarian syllable rules; Latin-script words in a Bulgarian run
|
||||
// (`WordCraft`, `email`) are hyphenated as English, as in any other language.
|
||||
if lang == ProofLang::Bg && core.iter().all(|&c| crate::bg::is_letter(c)) {
|
||||
return (crate::bg::points(core), Source::Rules);
|
||||
}
|
||||
if lang == ProofLang::Pt {
|
||||
return core_breaks_pt(core);
|
||||
}
|
||||
let dict = Dictionary::en_us();
|
||||
let s: String = core.iter().collect();
|
||||
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
|
||||
@@ -140,6 +160,21 @@ fn core_breaks_pl(core: &[char]) -> (Vec<usize>, Source) {
|
||||
(heuristic_points_pl(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// [`core_breaks_pl`] for Portuguese: the bundled patterns, then the generic heuristic.
|
||||
fn core_breaks_pt(core: &[char]) -> (Vec<usize>, Source) {
|
||||
let pats = Patterns::pt();
|
||||
let s: String = core.iter().collect();
|
||||
if let Some(p) = pats.exception(&s) {
|
||||
return (p.to_vec(), Source::Dictionary);
|
||||
}
|
||||
if !pats.is_empty()
|
||||
&& let Some(p) = pats.points(core)
|
||||
{
|
||||
return (p, Source::Patterns);
|
||||
}
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]).
|
||||
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||
word_breaks_in(word, ProofLang::En)
|
||||
@@ -455,16 +490,37 @@ mod tests {
|
||||
assert!(hyphen_points("typography", &lim).is_empty());
|
||||
}
|
||||
|
||||
/// The fastest of three timings of `f`, after a warm-up.
|
||||
fn fastest(f: impl Fn()) -> f64 {
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
/// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the
|
||||
@@ -598,7 +654,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hyphenating_a_long_word_is_linear() {
|
||||
// A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in
|
||||
// A word of n letters against one of 8n: linear ~8x the cost, the old rescans ~64x. One in
|
||||
// the pattern alphabet, one outside it (the syllable heuristic).
|
||||
let pattern = |k: usize| "hyphenation".repeat(k);
|
||||
let heuristic = |k: usize| "smørrebrød".repeat(k);
|
||||
@@ -607,17 +663,16 @@ mod tests {
|
||||
let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)]
|
||||
.into_iter()
|
||||
.map(|(word, repeat)| {
|
||||
let (small, big) = (word(400), word(1600));
|
||||
let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default()))));
|
||||
let (t1, t4) = (time(&small), time(&big));
|
||||
let ratio = t4 / t1.max(1e-9);
|
||||
let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::<String>());
|
||||
let (small, big) = (word(400), word(8 * 400));
|
||||
let time = |w: &str| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())));
|
||||
let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big));
|
||||
let what = format!("{}…: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::<String>());
|
||||
eprintln!("{what}");
|
||||
(what, ratio)
|
||||
})
|
||||
.collect();
|
||||
for (what, ratio) in ratios {
|
||||
assert!(ratio < 8.0, "4x the letters, not linear: {what}");
|
||||
assert!(ratio < 24.0, "8x the letters, not linear: {what}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +702,40 @@ mod tests {
|
||||
assert_eq!(p.exception("nigdy"), Some(&[2][..]));
|
||||
}
|
||||
|
||||
fn pt(w: &str) -> String {
|
||||
hyphenate_word_in(w, &Limits::default(), ProofLang::Pt)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_patterns_load() {
|
||||
let p = Patterns::pt();
|
||||
assert!(p.len() > 300, "{}", p.len());
|
||||
// `hard-ware`, `soft-ware` — and the file's last pattern line, a bare `1-`, which the
|
||||
// reader counts as a (never-matching) exception because it contains a hyphen.
|
||||
assert_eq!(p.exception_count(), 3);
|
||||
assert_eq!(p.exception("hardware"), Some(&[4][..]));
|
||||
assert_eq!(p.exception("software"), Some(&[4][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_words_hyphenate_by_portuguese_rules() {
|
||||
for (w, want) in [
|
||||
("problema", "pro-ble-ma"),
|
||||
("coração", "co-ra-ção"),
|
||||
("paralelepípedo", "pa-ra-le-le-pí-pe-do"),
|
||||
("computador", "com-pu-ta-dor"),
|
||||
("escrever", "es-cre-ver"),
|
||||
("saudade", "sau-da-de"),
|
||||
("extraordinário", "ex-tra-or-di-ná-rio"),
|
||||
("automático", "au-to-má-ti-co"),
|
||||
// One syllable: no break.
|
||||
("flor", "flor"),
|
||||
("bem", "bem"),
|
||||
] {
|
||||
assert_eq!(pt(w), want, "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_words_hyphenate_by_polish_rules() {
|
||||
for (w, want) in [
|
||||
@@ -698,4 +787,19 @@ mod tests {
|
||||
assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Other), hyphen_points(w, &Limits::default()), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulgarian_words_follow_the_bulgarian_rules() {
|
||||
let bg = |w: &str| hyphenate_word_in(w, &Limits::default(), ProofLang::Bg);
|
||||
assert_eq!(bg("сричкопренасяне"), "срич-ко-пре-на-ся-не");
|
||||
assert_eq!(bg("Таблица"), "Та-бли-ца");
|
||||
// A single letter is never left on a line (the paragraph limits): България, not Бъл-га-ри-я.
|
||||
assert_eq!(bg("България"), "Бъл-га-рия");
|
||||
assert_eq!(bg("„шофьор“,"), "„шо-фьор“,");
|
||||
assert_eq!(word_breaks_in("таблица", ProofLang::Bg).1, Source::Rules);
|
||||
// Latin words in a Bulgarian run are hyphenated as English.
|
||||
for w in ["publication", "WordCraft"] {
|
||||
assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Bg), hyphen_points(w, &Limits::default()), "{w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user