mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 01:31:18 +00:00
Merge upstream/main into feat/322-view-windows
This commit is contained in:
commit
65f36f9b8a
239 files changed
+72434
-2041
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,62 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
|
||||
# only the next contributor's local run discovers.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
|
||||
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
|
||||
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
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
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -43,6 +43,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
|
||||
@@ -11,10 +11,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
|
||||
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
|
||||
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
|
||||
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
|
||||
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
|
||||
| `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") |
|
||||
@@ -74,7 +76,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `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/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
|
||||
Generated
+38
-21
@@ -704,6 +704,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -2883,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5131,9 +5140,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
@@ -5164,7 +5174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -5177,12 +5187,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5193,9 +5204,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5209,11 +5221,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"image",
|
||||
@@ -5232,9 +5245,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
@@ -5261,7 +5275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5274,10 +5288,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5291,14 +5306,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5312,12 +5327,13 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"wordcraft-proof",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5329,7 +5345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5337,8 +5353,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -5357,7 +5374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5366,7 +5383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5380,7 +5397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-ui",
|
||||
@@ -5407,7 +5424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5426,7 +5443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5539,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -35,6 +35,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
|
||||
@@ -52,6 +52,28 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
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.
|
||||
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
|
||||
and permission notice is in the file:
|
||||
|
||||
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
|
||||
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png" width="128" height="128" alt="WordCraft icon: a document page and pen on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">WordCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -25,6 +29,9 @@
|
||||
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-3b5bdb?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-2b47b5?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_wordcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/wordcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -117,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 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
|
||||
|
||||
+122
-7
@@ -2,8 +2,8 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a seLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -12,11 +12,13 @@ agent control. This page is the summary; the assessment is
|
||||
[`docs/gaps.md`](docs/gaps.md), and milestones and the current focus are in
|
||||
[`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
> **Comment command update:** 2026-10-14 · malformed explicit IDs rejected; synthetic refusal, undo/redo and DOCX save/reopen checks (#1690 finding 3). Readiness estimates unchanged.
|
||||
|
||||
## Headline numbers (2026-10-10)
|
||||
|
||||
| Number | Value | Kind |
|
||||
| --- | --- | --- |
|
||||
| Ribbon/menu catalog coverage | **496 / 507 (97.8%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **502 / 513 (97.9%)** | 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) |
|
||||
@@ -112,11 +114,11 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) |
|
||||
| Vietnamese | vi | partial (#1434) | ~95% |
|
||||
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Slovenian (`sl`) covers every key of every catalog. 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 proofing is not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
|
||||
2,448-entry catalog, also without proofing.
|
||||
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language. Word ships
|
||||
2,448-entry catalog, also without proofing. Croatian (`hr`, #1271) has a complete catalog.
|
||||
Proofing: English, Polish, Portuguese, Italian, Croatian, Turkish and Russian (spelling, grammar; hyphenation except Croatian), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language; Bulgarian and German hyphenation only. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -134,21 +136,67 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
|
||||
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
|
||||
|
||||
Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged.
|
||||
|
||||
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
- 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme
|
||||
- 2026-10-21: Table styles shade the same banded rows in Word and WordCraft: our banded styles (Grid Table 4…) and new table styles write their band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`), which Word otherwise reads as zero and leaves unbanded, and a file's style without them is unbanded here too (#3415).
|
||||
- 2026-10-21: Screen readers: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172). The
|
||||
boxes announced themselves only as "edit" and the arrows beside them only as "button".
|
||||
- 2026-10-20: A click below a floating table (one dragged by its move handle, or from a .docx) goes to the paragraph beside or after it, as in Word, instead of a cell of its last row, so the text after a moved table can be reached with the mouse again (#2038).
|
||||
- 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899).
|
||||
- 2026-10-20: Headers and footers: closing them with Esc or Close Header and Footer puts the caret back where it was in the body, as Word does, instead of on page 1 (the view no longer jumps to the top of the document, #3331).
|
||||
- 2026-10-21: A document with many content controls saves in the time the controls take (#13): the writer looked for each paragraph's markers in the whole set of markers, once per paragraph, and compared every control with every other one to find the ones that clash, so a document with 32,000 controls took four seconds to save. Both are a lookup and a sweep now, and what is written does not change.
|
||||
- 2026-10-20: Restrict Editing: with Filling in forms enforced, the Developer tab's content-control buttons and Properties grey out in the ribbon, their tooltips saying why, as in Word; before, they looked live and only refused when clicked.
|
||||
- 2026-10-20: The page tip shown while dragging the vertical scrollbar stays on one line; it broke into two or three lines ("Page 10 of" / "40") as the page number grew during the drag (#3341).
|
||||
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
|
||||
- 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey.
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. |
|
||||
| 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. |
|
||||
| 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) |
|
||||
| 2026-10-19 | Personal templates (#2852): File › New lists the `.dotx` and `.dotm` files in your templates folder under Personal (a subfolder per category; the search box covers them), and one opens as a new untitled document attached to it, as in Word. Save As › Save as Word Template starts in that folder, made on first use in the settings folder; File › Options › Advanced › File locations › User templates points it elsewhere. Agents list them with `file.listTemplates`. |
|
||||
| 2026-10-19 | Delete with whole table cells selected (rows, columns, the table or one cell) empties each cell to one paragraph, as in Word: no blank paragraphs are left behind, the paragraph keeps the formatting of the cell's last one, and tables nested in the cells go too. One undo brings everything back (#2802). |
|
||||
| 2026-10-19 | A table-of-contents entry whose text just fits no longer leaves its page number alone at the start of the next line: the tab goes there with it, leader and all (#1840). |
|
||||
| 2026-10-19 | A Thai run with Latin letters and digits in a missing Thai font is drawn in one face on Linux too (#1549): the fallback no longer stops at Noto Sans Thai, which has no Latin letters, but finds an installed font covering the whole run (IBM Plex Sans Thai, say) and loads its whole family, so regular text gets the Regular face. Concurrent lookups no longer cache a miss. |
|
||||
| 2026-10-19 | Word signature lines (Insert › Signature Line) survive a save: a .docx saved by WordCraft keeps each one as a signature line, still signable in Word, with its suggested signer, instead of turning it into a plain picture. |
|
||||
| 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) |
|
||||
| 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. |
|
||||
| 2026-10-20 | Set Proofing Language › Set As Default persists for future blank documents and plain-text files, independently of the interface language; existing DOCX and templates keep their languages, and rejected commands retain the previous saved default (#2882). |
|
||||
| 2026-10-19 | Typing on Linux no longer stops after clicking Search (#2986): Search, Go To's page box, the Icons search and a new caption label asked for the keyboard on every frame, and each request reset the input method; they now ask only while they lack it. |
|
||||
| 2026-10-19 | An equation from LibreOffice in an `.odt` file no longer shows blank (#3099): it's embedded as a `draw:object` formula sub-document holding MathML, with a `draw:image` replacement preview in LibreOffice's own metafile format WordCraft can't decode as a picture; the formula's own MathML is read instead, through the same reader as HTML equations. |
|
||||
| 2026-10-19 | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item was a plain command call that did nothing and cleared the section's start |
|
||||
| 2026-10-19 | A picture, shape or text box dragged near the bottom of a page lands where it is dropped. It used to jump far below the page, cut off at its edge with only its selection box showing over the next page (#3169) |
|
||||
| 2026-10-19 | Insert Caption picks the label used last, as Word does, and keeps it between runs (#3120). |
|
||||
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
|
||||
| 2026-10-20 | Russian sorting keeps ё with е and recognises decomposed ё/й: case-insensitive words and index headings share the corrected collation, with regressions and no new dependencies. |
|
||||
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
|
||||
| 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
|
||||
| 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. |
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). |
|
||||
| 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes |
|
||||
| 2026-10-16 | English dialects (#1281, #1045): English (UK), (Australia), (Canada) and the other Englishes are proofed in their own spelling — *colour* is flagged in a US document and *color* in a British one, with the right form suggested first — from our own list of American/British spellings, so nothing to install. Review › Language lists them, the .docx `w:lang` keeps them, and an English interface on a British (or Canadian, Australian…) system starts new documents in that English |
|
||||
| 2026-10-15 | Triple borders (tables, paragraphs, the `###` AutoFormat line) draw as three lines on screen and in PDF, across the line for vertical borders too; they used to draw as double lines (#1740) |
|
||||
| 2026-10-16 | Pictures and shapes without text stories turn visibly while dragging the rotation handle (#2277), with Shift snapping to 15° steps; the document changes only on release through one `arrange.rotation` command, and Escape cancels the preview |
|
||||
| 2026-10-16 | DOCX: a text frame (`w:framePr`) in the document's paragraph defaults frames no paragraph, as in Word (#2360). Files from Apple Pages carry a sizeless one at the top left, which put every paragraph in the corner of the page: a header's title over the first table, the footer's page number out of sight, table rows of the wrong height |
|
||||
| 2026-10-16 | A plain-text or Markdown file opened and saved is saved: closing it no longer asks to save again, and Save in that prompt closes the window (#2376). Saving over the document's own file used to count as a copy, like Save As to plain text elsewhere, so the prompt came back after every save and only Don't Save closed the app |
|
||||
| 2026-10-16 | Dragging an In Line with Text picture, shape or text box moves it within the text, as in Word: a dashed insertion cursor follows the pointer through the text, and the drop puts the object there, still inline and selected, in one undo step (Ctrl+drag drops a copy there). Before, the drop floated it with Square wrapping where it was let go. Scripts and MCP use `arrange.bounds {"to": Pos}` / `arrange.copyTo {"to": Pos}` (#2233, #2234) |
|
||||
| 2026-10-16 | DOCX: a `HYPERLINK` field (complex or simple) reads as a link, as a `w:hyperlink` does: its result stays ordinary text carrying the link (`url`, `url#bookmark` or `#bookmark`), so a tab in it reaches its tab stop and a `PAGEREF` in it stays a field (#2174). A table of contents from Word for the web, whose entries are such fields, showed a box for each tab and no dot leaders; the entries now open as in Word and their links work |
|
||||
| 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard |
|
||||
| 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-19 | Typing no longer starts render threads on every repaint (#3027): page, area and icon renders share one kept vello_cpu context, reset to each size, instead of building a multi-threaded one (and its thread pool) per render. Typing about 150 characters in 15 s created 295–388 threads before and none now; CPU time while typing fell by about a third (Windows, debug build). |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. |
|
||||
| 2026-10-18 | Dragging a single selected cell's border (Select Cell, left-edge arrow, triple-click on its last paragraph) moves it in its row only; the neighbour takes up the difference. Checked against Word for Mac 16.113.4. |
|
||||
| 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 |
|
||||
| 2026-10-17 | Restrict Editing holds for structure too (#1327): an exception inside a table cell no longer lets table, row or cell formatting change the protected rest of the table; formatting limited to styles refuses Paste Formatting, table shading, borders, cell alignment, cell margins, text direction, table-style edits and locked table styles; loading, filtering or sorting recipients no longer rewrites merge-field results in a protected document. |
|
||||
| 2026-10-18 | Manage Sources › Import… (#2640) reads a Word bibliography file (`b:Sources`: Word's own `Sources.xml` master list) into the document: the tags, names, kinds and fields Word saves are read by the same code that reads the bibliography custom XML part of a `.docx`, a tag already in the document is left alone (an import never overwrites an edited source), an untagged source gets a tag the way a new one does, the whole import is one undo step, and a file that isn't `b:Sources` is refused. |
|
||||
@@ -161,6 +209,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked |
|
||||
| 2026-10-15 | The caret, clicks and selection edges fall between the letters of a ligature (fi, ffi, lam-alef), as in Word: the glyph's advance is shared evenly among its letters, mirrored in right-to-left text (#1974) |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
| 2026-10-17 | Home › Underline's drop-down shows each underline as a sample of its line, adds Dot-dot-dash, None and More Underlines…, and an Underline Color submenu backed by the new `format.underlineColor` command (catalog 490/505) (#2738) |
|
||||
| 2026-10-16 | Update Field is offered on a table of contents after a save and reopen, and repeated save and update adds no blank line (#2182; the flow in that report already worked since #1197) |
|
||||
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
|
||||
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
|
||||
@@ -173,6 +222,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
|
||||
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
|
||||
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|
||||
| 2026-10-16 | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. |
|
||||
| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form |
|
||||
| 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right |
|
||||
| 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. |
|
||||
@@ -199,6 +249,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
| 2026-10-11 | View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling |
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
@@ -215,6 +267,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Missing Arial Unicode MS tries Arial and its proportional substitutes before code-font heuristics. |
|
||||
| 2026-10-16 | Web Layout text fills the window width and re-wraps once a resize settles, or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876). |
|
||||
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
|
||||
| 2026-10-18 | Shape fills keep and draw gradients and transparency (#1500): Word 365 cover pages show their theme gradient panel and translucent wave; DrawingML and VML shapes, text boxes, group members (`a:grpFill`) and canvases are read, drawn (screen, PNG, PDF) and saved with their gradient stops, angles, focus and transparency; theme fill styles (`a:fillRef`) resolve to the fill they name. `.json` documents keep opaque fills as `[r,g,b]`. Edited as in Word: Shape Fill › Gradient (Light and Dark Variations) and the Format Shape pane (solid colour and transparency; gradient presets, type, direction, angle and stops) |
|
||||
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
|
||||
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
|
||||
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
|
||||
@@ -230,10 +283,13 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
|
||||
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
|
||||
| 2026-10-18 | Legacy menu keys (#1697; not on macOS): Alt, V, U turns on Full Screen View, as in Word for Windows, along with 17 other Word 2003 menu sequences observed there (views, ruler, zoom, Select All, Find/Replace/Go To, Symbol, Picture, Font, Paragraph, Word Count, Insert Table). Hidden as in Word, with a hint under the tab row while a sequence waits |
|
||||
| 2026-10-18 | View › Focus now fills the screen (#1678): the document on black with a slim bar whose "⋯" drops the ribbon over the page. Full Screen View (`view.fullScreenView`) shows the pages alone with Search at the top and can be bound to any key in Customize Keyboard; the two turn each other off. F11's Full Screen (#980) still fills the screen with the whole window. Esc leaves either; Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon, and a shortcut of your own on Ctrl+F1 wins. Immersive Reader no longer switches Focus on |
|
||||
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
|
||||
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
|
||||
| 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) |
|
||||
| 2026-10-15 | Word's native ink inside groups and drawing canvases survives DOCX saves (gap 2): the ink member keeps its InkML part, relationship, content type and picture fallback, stays in its place among the members, moves with the group and is drawn from its fallback picture; deleting the group drops its part |
|
||||
| 2026-10-15 | Crash recovery copies and native .json files store pictures and kept parts as base64 text instead of arrays of numbers (#586 follow-up, #518): with a 27 MiB picture a recovery copy is 36 MiB instead of 96 MiB and a .json file 36 MiB instead of 286 MiB, written and read about five times faster. Copies and files written before still open |
|
||||
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
|
||||
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
|
||||
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
@@ -241,6 +297,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. |
|
||||
| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it |
|
||||
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
|
||||
| 2026-10-15 | Comments in headers, footers, notes and text boxes are listed with their place in the document, get a balloon beside their text, and Next, Previous and Go to reach them in reading order: the body with its notes and text boxes, then headers and footers. A comment in a story nothing shows (a removed header, a deleted note's text, a first-page or even-page header whose option is off, a linked text box's own text) isn't navigated to (#2289) |
|
||||
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
|
||||
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
|
||||
| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). |
|
||||
@@ -258,10 +315,12 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-14 | Update Field works inside headers, footers, notes and text boxes, on the part being edited, as in Word (#1706); with the caret in the body it still leaves them alone, and printing updates every field in the document |
|
||||
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
|
||||
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
|
||||
| 2026-10-14 | Comment commands refuse explicit fractional, negative or wrong-type IDs (#1690, finding 3). Malformed IDs no longer truncate floats or fall back to the caret; omitted IDs still use the caret for Delete and Resolve. Refused commands keep the document, selection and undo/redo history; valid operations have synthetic repeated DOCX save/reopen coverage |
|
||||
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
|
||||
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
|
||||
| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) |
|
||||
| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. |
|
||||
| 2026-10-14 | Tables nest 24 deep at most, the depth the .docx reader and writer keep (#1596): pasting a table into its own cell again and again no longer builds a document that crashes when it is saved as JSON, copied, closed or replaced by a new one. A table that would go deeper arrives as its paragraphs, text kept, and Insert › Table in a cell 24 tables deep says why it can't. Word has no limit |
|
||||
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
|
||||
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
|
||||
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
|
||||
@@ -299,10 +358,12 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
|
||||
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
|
||||
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
|
||||
| 2026-10-16 | Arabic words with lam-alef copy and search whole in exported PDFs with fonts that draw لا as a lam and a zero-width alef at one spot, such as the static Noto Naskh Arabic builds (#2310): السلام no longer reads back as السالم. Clusters drawn at the same place now go in reverse text order, so viewers reverse the run as a whole again. The page looks the same |
|
||||
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
|
||||
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
|
||||
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
|
||||
| 2026-10-14 | Bold text in Arial Black and other Black or Heavy fonts looks as in Word: older copies of these fonts (Arial Black in the core fonts) call themselves regular weight, so bold was drawn with an extra synthetic bold; a family named Black/Heavy now counts as that heavy |
|
||||
| 2026-10-14 | German text hyphenates by German rules: runs in German (`de`, `de-DE`, `de-AT`, `de-CH`) use hyph-utf8's German patterns for the reformed spelling (MIT), so automatic hyphenation gives `un-ter-lie-gen-de` and `öf-fent-lich-recht-li-cher` instead of English-pattern breaks like `unterlieg-ende` and `rechtli-cher`; Swiss `ss` splits like any double consonant (`Stras-se`). German still has no spelling or grammar marks (#1564) |
|
||||
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
|
||||
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
|
||||
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
|
||||
@@ -340,6 +401,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
|
||||
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
|
||||
| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge |
|
||||
| 2026-10-15 | Double-clicking the page number in a later page's footer opens the footer on that page (#2103): the double-click's first click moved the caret to the body text above and scrolled the view up to it; a click in the header/footer band from the body now leaves the caret and the view alone, as in Word |
|
||||
| 2026-10-15 | The text pointer shows on white paper on Windows (#1746, #1417, #1126): Windows' inverting I-beam stayed white over the page, so WordCraft hides it there and draws its own, black on light paper and white on dark, edged in the other colour |
|
||||
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
@@ -469,6 +532,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 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-15 | Slovenian (`sl`) interface: 2,614 catalog entries (every key of every other catalog, including native print settings), `sl-SI` system locales and a saved language choice; locale, catalog and bundled-font tests; no Slovenian proofing resources |
|
||||
| 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources |
|
||||
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
|
||||
| 2026-10-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet |
|
||||
@@ -478,6 +542,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
|
||||
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
|
||||
| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
|
||||
| 2026-10-17 | Recovery: a snapshot that fails on its own size, the folder or disk budget, or a value the format cannot keep is not rewritten every 10 s while nothing changes; it waits for the next edit (#2625). Transient failures still retry. |
|
||||
| 2026-10-12 | Legacy table paragraph spacing: `allowSpaceOfSameStyleInTable` survives DOCX save; enabled modes 11/12/14 keep contextual same-style spacing in cells. Mode 15 and non-cell controls unchanged; measured against Word for Mac 16.113.3 |
|
||||
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
|
||||
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
|
||||
@@ -500,6 +565,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-15 | Lists (#1683): a bullet stored as a private-use character its font can't draw (the font isn't installed) shows the level's default bullet (• ○ ▪); it used to be whatever an installed icon font has at that code, such as Font Awesome's Twitter logo |
|
||||
| 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-17 | Saving into a folder that refuses a temporary file (macOS privacy protection, read-only folders, #2138) never overwrites an existing document in place: it is left untouched and the error says to use Save As to another folder or allow WordCraft access to the folder (System Settings › Privacy & Security › Files and Folders on macOS). A new file is created directly. Unit tests on Linux; not yet confirmed on a Mac |
|
||||
| 2026-10-20 | Saving to a NAS share mounted by GVFS (`smb://` in the Linux file manager, #3003) works: the share refuses every Unix mode change with "Operation not supported", which used to fail the whole save, so the close prompt's Save never closed. The save now goes on without copying a mode the folder doesn't keep; any other failure still keeps the original. Unit tests; not yet confirmed on a real share |
|
||||
|
||||
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
|
||||
|
||||
@@ -507,13 +573,46 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
|
||||
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
|
||||
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
|
||||
| 2026-10-19 | trivial | Text context menus include the existing formatting mini toolbar (#2959); paste options remain in PR #2599. Readiness estimates unchanged. |
|
||||
| 2026-10-19 | trivial | Progress log: Insert Caption picks the label used last (#3120) |
|
||||
| 2026-10-16 | trivial | Progress log: `HYPERLINK` fields read as links (#2174) |
|
||||
| 2026-10-18 | trivial | Progress log: German text hyphenates by the German patterns |
|
||||
| 2026-10-21 | trivial | Progress log: File › Recover follows the app theme (#3243) |
|
||||
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
|
||||
| 2026-10-21 | trivial | Progress log: table style band sizes, banded rows as in Word (#3415) |
|
||||
| 2026-10-21 | trivial | Progress log: ODT table column widths are read, so widths set in LibreOffice or changed in WordCraft survive a save |
|
||||
| 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. |
|
||||
| 2026-10-21 | trivial | Progress log: a document with many content controls saves in the time the controls take (#13) |
|
||||
| 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged |
|
||||
| 2026-10-20 | trivial | Progress log: a click below a floating table goes to the text after it, not its last row (#2038) |
|
||||
| 2026-10-20 | trivial | Progress log: the table handles show while the caret is in the table (#2428) |
|
||||
| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. |
|
||||
| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) |
|
||||
| 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings |
|
||||
| 2026-10-20 | trivial | Progress log: closing the header returns to the body selection (#3331) |
|
||||
| 2026-10-20 | trivial | Progress log: forms protection greys out the Developer tab's control buttons and Properties |
|
||||
| 2026-10-20 | trivial | Progress log: the scrollbar page tip stays on one line (#3341) |
|
||||
| 2026-10-19 | trivial | Progress log: typing no longer stops after clicking Search (#2986) |
|
||||
| 2026-10-19 | trivial | Progress log: Delete on selected table cells leaves one empty paragraph in each (#2802) |
|
||||
| 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) |
|
||||
| 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) |
|
||||
| 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) |
|
||||
| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control |
|
||||
| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised |
|
||||
| 2026-10-17 | trivial | Progress log: #2048 entry covers anchors pushed onto the next page |
|
||||
| 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) |
|
||||
| 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 |
|
||||
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
|
||||
| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): View catalog 25/29 → 29/29, total 493/504 (97.8%) |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) |
|
||||
| 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) |
|
||||
| 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) |
|
||||
| 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only |
|
||||
| 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) |
|
||||
| 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) |
|
||||
| 2026-10-18 | trivial | Progress log: Picture Format › Size, sizes typed in any unit and the Size dialog (#2573); readiness estimates unchanged |
|
||||
@@ -522,6 +621,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
|
||||
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
|
||||
| 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. |
|
||||
| 2026-10-18 | trivial | Progress log: legacy menu keys (#1697) |
|
||||
| 2026-10-18 | trivial | Progress log: Focus fills the screen; Full Screen View (#1678) |
|
||||
| 2026-10-16 | trivial | Progress log: the Spike |
|
||||
| 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save |
|
||||
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
|
||||
@@ -530,6 +631,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) |
|
||||
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
|
||||
| 2026-10-18 | trivial | Progress log: bold Black/Heavy fonts are not emboldened again |
|
||||
| 2026-10-18 | trivial | Progress log: shape gradients and transparency (#1500) |
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
|
||||
@@ -563,6 +665,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) |
|
||||
| 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) |
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. |
|
||||
| 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) |
|
||||
| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) |
|
||||
| 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands |
|
||||
@@ -585,6 +688,11 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-17 | minor | Progress log: Scale to paper size on File › Print, #2108 |
|
||||
| 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position |
|
||||
| 2026-10-15 | trivial | Progress log: caret inside ligatures (#1974) |
|
||||
| 2026-10-17 | trivial | Progress log: the ribbon's paste choices in the context menu (#2583) |
|
||||
| 2026-10-16 | trivial | Progress log: live rotation preview while dragging (#2277); readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) |
|
||||
| 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) |
|
||||
| 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
@@ -597,6 +705,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
|
||||
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
|
||||
| 2026-10-15 | trivial | Progress log: comments outside the body in the list, balloons and Next/Previous (#2289) |
|
||||
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
|
||||
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
|
||||
| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows |
|
||||
@@ -609,9 +718,11 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) |
|
||||
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
|
||||
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
|
||||
| 2026-10-14 | trivial | Comment commands reject malformed explicit IDs instead of truncating floats or targeting the caret (#1690, finding 3); synthetic command, undo/redo and DOCX save/reopen coverage; readiness estimates unchanged |
|
||||
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
|
||||
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
|
||||
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |
|
||||
| 2026-10-14 | trivial | Progress log: tables nest 24 deep at most (#1596) |
|
||||
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
|
||||
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
|
||||
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
|
||||
@@ -641,6 +752,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
|
||||
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
|
||||
| 2026-10-16 | trivial | Progress log: lam-alef drawn as two glyphs at one x extracts whole from exported PDFs (#2310) |
|
||||
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
|
||||
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
|
||||
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
|
||||
@@ -774,7 +886,10 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
|
||||
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
|
||||
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
|
||||
| 2026-10-15 | trivial | Charts: radar charts (standard, marker, filled) are drawn instead of a placeholder (#1890) |
|
||||
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
|
||||
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
|
||||
| 2026-10-15 | trivial | Progress log: ruler numbers by zoom, rulers follow the caret's page (#1878) |
|
||||
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
|
||||
| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) |
|
||||
| 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) |
|
||||
@@ -471,12 +471,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
(None, Some(t)) => {
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
_ => session(wordcraft_doc::Document::new()),
|
||||
};
|
||||
if let Some(name) = arg_value(&rest, "--author") {
|
||||
set_author(&mut s, &name)?;
|
||||
|
||||
@@ -40,10 +40,11 @@ fn html_converts_onto_the_region_paper() {
|
||||
|
||||
#[test]
|
||||
fn every_run_session_follows_the_region() {
|
||||
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
|
||||
// `run --file`, `run --template` and a bare `run` (with or without `file.new`) all start a session in the system region,
|
||||
// so `file.new` gives the same paper whichever way the session began.
|
||||
for args in [
|
||||
&["run", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
&["run", "--cmd", "text.insert={\"text\":\"x\"}", "--save", "{out}"][..],
|
||||
&["run", "--template", "blank", "--save", "{out}"][..],
|
||||
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
] {
|
||||
|
||||
@@ -23,6 +23,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -93,8 +93,12 @@ fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
|
||||
match req.clone() {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
|
||||
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
|
||||
FileDialogRequest::Save { name } => {
|
||||
FileDialogRequest::Save { name, dir } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
// Save as Word Template starts in the user templates folder (#2852).
|
||||
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -103,20 +106,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -435,8 +443,21 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -80,7 +80,7 @@ impl eframe::App for App {
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.0.quit_requested = false;
|
||||
schedule_macos_terminate(&self.0, self.2.as_ref());
|
||||
schedule_macos_terminate(&mut self.0, self.2.as_ref());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.0.quit_requested {
|
||||
@@ -97,6 +97,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
self.0.session.finish_recovery();
|
||||
save_prefs(&self.0);
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
@@ -115,7 +116,7 @@ impl eframe::App for App {
|
||||
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
|
||||
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
@@ -127,6 +128,7 @@ fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::Key
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
app.session.finish_recovery();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
@@ -250,6 +252,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
print_native: Some(print::submit()),
|
||||
clipboard_picture: Some(Box::new(clipboard::picture)),
|
||||
make_default_app: default_apps::hook(),
|
||||
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
|
||||
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
@@ -309,10 +313,10 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
let opened = wordcraft_engine::io::read_document(path)
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
@@ -527,6 +531,40 @@ mod tests {
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));
|
||||
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));
|
||||
std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();
|
||||
// If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.
|
||||
let unblock = fifo.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(unblock);
|
||||
});
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();
|
||||
for p in [&fifo, &zero] {
|
||||
let started = std::time::Instant::now();
|
||||
open_non_unicode(&mut app, p);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());
|
||||
assert_eq!(app.session.path, None, "{p:?}");
|
||||
assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,305 @@
|
||||
# English spelling variants: American and British forms of the same word, for the English
|
||||
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
|
||||
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
|
||||
#
|
||||
# Each line: POLICY AMERICAN BRITISH
|
||||
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
|
||||
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
|
||||
# u only the American form is right there (the British one is flagged, American suggested)
|
||||
# b only the British form is right there
|
||||
# * both are right
|
||||
# Inflections (colours, centred, organising, organisation…) come from the rules in
|
||||
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
|
||||
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
|
||||
# accepted: a false alarm is worse than a missed variant.
|
||||
|
||||
# -or / -our
|
||||
ubb arbor arbour
|
||||
ubb ardor ardour
|
||||
ubb armor armour
|
||||
ubb armory armoury
|
||||
ubb behavior behaviour
|
||||
ubb behavioral behavioural
|
||||
ubb candor candour
|
||||
ubb clamor clamour
|
||||
ubb color colour
|
||||
ubb demeanor demeanour
|
||||
ubb discolor discolour
|
||||
ubb dishonor dishonour
|
||||
ubb endeavor endeavour
|
||||
ubb favor favour
|
||||
ubb favorite favourite
|
||||
ubb favoritism favouritism
|
||||
ubb fervor fervour
|
||||
ubb flavor flavour
|
||||
ubb harbor harbour
|
||||
ubb honor honour
|
||||
ubb humor humour
|
||||
ubb labor labour
|
||||
ubb misbehavior misbehaviour
|
||||
ubb neighbor neighbour
|
||||
ubb neighborhood neighbourhood
|
||||
ubb neighborly neighbourly
|
||||
ubb odor odour
|
||||
ubb parlor parlour
|
||||
ubb rancor rancour
|
||||
ubb rigor rigour
|
||||
ubb rumor rumour
|
||||
ubb savior saviour
|
||||
ubb savor savour
|
||||
ubb savory savoury
|
||||
ubb splendor splendour
|
||||
ubb succor succour
|
||||
ubb tumor tumour
|
||||
ubb unfavorable unfavourable
|
||||
ubb valor valour
|
||||
ubb vapor vapour
|
||||
ubb vigor vigour
|
||||
|
||||
# -er / -re
|
||||
ubb caliber calibre
|
||||
ubb center centre
|
||||
ubb centimeter centimetre
|
||||
ubb epicenter epicentre
|
||||
ubb fiber fibre
|
||||
ubb kilometer kilometre
|
||||
ubb liter litre
|
||||
ubb luster lustre
|
||||
ubb maneuver manoeuvre
|
||||
ubb meager meagre
|
||||
ubb millimeter millimetre
|
||||
ubb miter mitre
|
||||
ubb ocher ochre
|
||||
ubb saber sabre
|
||||
ubb sepulcher sepulchre
|
||||
ubb somber sombre
|
||||
ubb specter spectre
|
||||
*bb theater theatre
|
||||
# A meter is also a measuring device in British English.
|
||||
u** meter metre
|
||||
|
||||
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
|
||||
uu* agonize agonise
|
||||
uu* apologize apologise
|
||||
uu* authorize authorise
|
||||
uu* capitalize capitalise
|
||||
uu* categorize categorise
|
||||
uu* centralize centralise
|
||||
uu* characterize characterise
|
||||
uu* civilize civilise
|
||||
uu* colonize colonise
|
||||
uu* commercialize commercialise
|
||||
uu* computerize computerise
|
||||
uu* criticize criticise
|
||||
uu* crystallize crystallise
|
||||
uu* customize customise
|
||||
uu* decentralize decentralise
|
||||
uu* demonize demonise
|
||||
uu* deodorize deodorise
|
||||
uu* destabilize destabilise
|
||||
uu* digitize digitise
|
||||
uu* dramatize dramatise
|
||||
uu* economize economise
|
||||
uu* emphasize emphasise
|
||||
uu* energize energise
|
||||
uu* equalize equalise
|
||||
uu* familiarize familiarise
|
||||
uu* fantasize fantasise
|
||||
uu* fertilize fertilise
|
||||
uu* finalize finalise
|
||||
uu* formalize formalise
|
||||
uu* fossilize fossilise
|
||||
uu* galvanize galvanise
|
||||
uu* generalize generalise
|
||||
uu* globalize globalise
|
||||
uu* harmonize harmonise
|
||||
uu* hospitalize hospitalise
|
||||
uu* humanize humanise
|
||||
uu* hypnotize hypnotise
|
||||
uu* hypothesize hypothesise
|
||||
uu* idealize idealise
|
||||
uu* idolize idolise
|
||||
uu* immobilize immobilise
|
||||
uu* immortalize immortalise
|
||||
uu* immunize immunise
|
||||
uu* industrialize industrialise
|
||||
uu* initialize initialise
|
||||
uu* italicize italicise
|
||||
uu* itemize itemise
|
||||
uu* jeopardize jeopardise
|
||||
uu* legalize legalise
|
||||
uu* legitimize legitimise
|
||||
uu* liberalize liberalise
|
||||
uu* localize localise
|
||||
uu* magnetize magnetise
|
||||
uu* marginalize marginalise
|
||||
uu* maximize maximise
|
||||
uu* mechanize mechanise
|
||||
uu* memorize memorise
|
||||
uu* mesmerize mesmerise
|
||||
uu* minimize minimise
|
||||
uu* mobilize mobilise
|
||||
uu* modernize modernise
|
||||
uu* monopolize monopolise
|
||||
uu* moralize moralise
|
||||
uu* nationalize nationalise
|
||||
uu* naturalize naturalise
|
||||
uu* neutralize neutralise
|
||||
uu* normalize normalise
|
||||
uu* optimize optimise
|
||||
uu* organize organise
|
||||
uu* ostracize ostracise
|
||||
uu* oxidize oxidise
|
||||
uu* pasteurize pasteurise
|
||||
uu* patronize patronise
|
||||
uu* penalize penalise
|
||||
uu* personalize personalise
|
||||
uu* plagiarize plagiarise
|
||||
uu* polarize polarise
|
||||
uu* popularize popularise
|
||||
uu* pressurize pressurise
|
||||
uu* prioritize prioritise
|
||||
uu* privatize privatise
|
||||
uu* publicize publicise
|
||||
uu* pulverize pulverise
|
||||
uu* randomize randomise
|
||||
uu* rationalize rationalise
|
||||
uu* realize realise
|
||||
uu* recognize recognise
|
||||
uu* reorganize reorganise
|
||||
uu* revolutionize revolutionise
|
||||
uu* satirize satirise
|
||||
uu* scrutinize scrutinise
|
||||
uu* sensitize sensitise
|
||||
uu* socialize socialise
|
||||
uu* specialize specialise
|
||||
uu* stabilize stabilise
|
||||
uu* standardize standardise
|
||||
uu* sterilize sterilise
|
||||
uu* stigmatize stigmatise
|
||||
uu* subsidize subsidise
|
||||
uu* summarize summarise
|
||||
uu* symbolize symbolise
|
||||
uu* sympathize sympathise
|
||||
uu* synchronize synchronise
|
||||
uu* synthesize synthesise
|
||||
uu* systematize systematise
|
||||
uu* tantalize tantalise
|
||||
uu* terrorize terrorise
|
||||
uu* theorize theorise
|
||||
uu* trivialize trivialise
|
||||
uu* urbanize urbanise
|
||||
uu* utilize utilise
|
||||
uu* vandalize vandalise
|
||||
uu* verbalize verbalise
|
||||
uu* victimize victimise
|
||||
uu* visualize visualise
|
||||
uu* vocalize vocalise
|
||||
uu* westernize westernise
|
||||
|
||||
# -yze / -yse
|
||||
u*b analyze analyse
|
||||
u*b catalyze catalyse
|
||||
u*b dialyze dialyse
|
||||
u*b electrolyze electrolyse
|
||||
u*b hydrolyze hydrolyse
|
||||
u*b paralyze paralyse
|
||||
u*b psychoanalyze psychoanalyse
|
||||
|
||||
# -ense / -ence
|
||||
ubb defense defence
|
||||
ubb offense offence
|
||||
ubb pretense pretence
|
||||
ubb defenseless defenceless
|
||||
# In British English "license" and "practise" are the verbs, so only American English flags.
|
||||
u** license licence
|
||||
u** practice practise
|
||||
|
||||
# Doubled l (American English accepts both)
|
||||
*bb canceled cancelled
|
||||
*bb canceling cancelling
|
||||
*bb channeled channelled
|
||||
*bb channeling channelling
|
||||
*bb counseled counselled
|
||||
*bb counseling counselling
|
||||
*bb counselor counsellor
|
||||
*bb counselors counsellors
|
||||
*bb dueling duelling
|
||||
*bb equaled equalled
|
||||
*bb equaling equalling
|
||||
*bb fueled fuelled
|
||||
*bb fueling fuelling
|
||||
*bb jeweler jeweller
|
||||
*bb jewelers jewellers
|
||||
*bb jewelry jewellery
|
||||
*bb labeled labelled
|
||||
*bb labeling labelling
|
||||
*bb leveled levelled
|
||||
*bb leveling levelling
|
||||
*bb marveled marvelled
|
||||
*bb marveling marvelling
|
||||
*bb marvelous marvellous
|
||||
*bb modeled modelled
|
||||
*bb modeling modelling
|
||||
*bb quarreled quarrelled
|
||||
*bb quarreling quarrelling
|
||||
*bb signaled signalled
|
||||
*bb signaling signalling
|
||||
*bb totaled totalled
|
||||
*bb totaling totalling
|
||||
*bb traveled travelled
|
||||
*bb traveler traveller
|
||||
*bb travelers travellers
|
||||
*bb traveling travelling
|
||||
*bb tunneled tunnelled
|
||||
*bb tunneling tunnelling
|
||||
*bb woolen woollen
|
||||
|
||||
# -og / -ogue
|
||||
*bb catalog catalogue
|
||||
*bb cataloged catalogued
|
||||
*bb cataloging cataloguing
|
||||
|
||||
# ae / oe
|
||||
u*b anemia anaemia
|
||||
u*b anemic anaemic
|
||||
u*b anesthesia anaesthesia
|
||||
u*b anesthetic anaesthetic
|
||||
**b archeology archaeology
|
||||
**b archeologist archaeologist
|
||||
u*b diarrhea diarrhoea
|
||||
u*b edema oedema
|
||||
u*b esophagus oesophagus
|
||||
u*b estrogen oestrogen
|
||||
u*b gynecologist gynaecologist
|
||||
u*b gynecology gynaecology
|
||||
u*b hematology haematology
|
||||
u*b hemoglobin haemoglobin
|
||||
u*b hemorrhage haemorrhage
|
||||
u*b leukemia leukaemia
|
||||
u*b orthopedic orthopaedic
|
||||
u*b pediatric paediatric
|
||||
u*b pediatrician paediatrician
|
||||
u*b pediatrics paediatrics
|
||||
|
||||
# Other words
|
||||
u*b aluminum aluminium
|
||||
u*b cozy cosy
|
||||
ubb mold mould
|
||||
ubb molded moulded
|
||||
ubb molding moulding
|
||||
ubb moldy mouldy
|
||||
ubb molt moult
|
||||
ubb pajamas pyjamas
|
||||
ubb plow plough
|
||||
ubb plowed ploughed
|
||||
ubb plowing ploughing
|
||||
u** program programme
|
||||
u*b skeptic sceptic
|
||||
u*b skeptical sceptical
|
||||
u*b skepticism scepticism
|
||||
ubb smolder smoulder
|
||||
ubb smoldering smouldering
|
||||
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
|
||||
uu* tire tyre
|
||||
uu* curb kerb
|
||||
@@ -166,3 +166,12 @@ real_name = "Max Murphy"
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
[people.asystemoffields]
|
||||
display_name = "asystemoffields"
|
||||
|
||||
[people.dmim2502-commits]
|
||||
display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
|
||||
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
|
||||
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
|
||||
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
|
||||
//!
|
||||
//! Reading takes either form, so `.json` files and recovery copies written before still open.
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::display::Base64Display;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
type Store = BTreeMap<String, Arc<Vec<u8>>>;
|
||||
|
||||
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
|
||||
struct Encoded<'a>(&'a [u8]);
|
||||
|
||||
impl Serialize for Encoded<'_> {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(&Base64Display::new(self.0, &STANDARD))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
|
||||
d.deserialize_map(StoreVisitor)
|
||||
}
|
||||
|
||||
struct StoreVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for StoreVisitor {
|
||||
type Value = Store;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map of part names to base64 text or byte arrays")
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
|
||||
let mut store = Store::new();
|
||||
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
|
||||
store.insert(name, Arc::new(bytes));
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
}
|
||||
|
||||
/// A part's bytes as either form.
|
||||
struct Bytes(Vec<u8>);
|
||||
|
||||
impl<'de> de::Deserialize<'de> for Bytes {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(BytesVisitor).map(Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
struct BytesVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for BytesVisitor {
|
||||
type Value = Vec<u8>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("base64 text or an array of bytes")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
|
||||
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
|
||||
}
|
||||
|
||||
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// The form written before: one number per byte.
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
|
||||
// The length hint comes from the file: trust it only so far.
|
||||
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
|
||||
while let Some(b) = seq.next_element::<u8>()? {
|
||||
out.push(b);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
|
||||
struct Parts {
|
||||
#[serde(default, with = "super")]
|
||||
media: Store,
|
||||
}
|
||||
|
||||
fn parts(entries: &[(&str, &[u8])]) -> Parts {
|
||||
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_are_written_as_base64_and_read_back() {
|
||||
let all: Vec<u8> = (0..=255).collect();
|
||||
for n in [0, 1, 2, 3, 4, 255, 256] {
|
||||
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
|
||||
let text = serde_json::to_string(&p).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_arrays_written_before_still_read() {
|
||||
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
|
||||
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_are_errors_not_panics() {
|
||||
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
|
||||
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
|
||||
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
|
||||
}
|
||||
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ const HU: &[Tailor] = &[
|
||||
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)];
|
||||
// Russian ё shares е's primary weight; the diaeresis breaks ties (CLDR Russian standard).
|
||||
const RU: &[Tailor] = &[ts("ё", 'е', 0, 4)];
|
||||
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)];
|
||||
@@ -184,6 +186,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
"sv" | "fi" => SV,
|
||||
"da" | "nb" | "nn" | "no" => DA,
|
||||
"es" | "gl" => ES,
|
||||
"ru" => RU,
|
||||
"tr" | "az" => TR,
|
||||
"hr" | "bs" | "sr" => HR,
|
||||
"ro" => RO,
|
||||
@@ -196,7 +199,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode
|
||||
/// A lowercase Latin or Russian 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)] = &[
|
||||
@@ -329,6 +332,8 @@ const COMPOSE: &[(char, char, char)] = &[
|
||||
('z', '\u{302}', 'ẑ'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('z', '\u{30c}', 'ž'),
|
||||
('е', '\u{308}', 'ё'),
|
||||
('и', '\u{306}', 'й'),
|
||||
];
|
||||
|
||||
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
|
||||
@@ -476,6 +481,25 @@ mod tests {
|
||||
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_yo_sorts_with_e_instead_of_after_ya() {
|
||||
for lang in ["ru", "ru-RU", "RU_ru"] {
|
||||
assert_eq!(sorted(&["яблоко", "ёлка", "жук", "ель", "ёж", "еж"], lang), ["еж", "ёж", "ёлка", "ель", "жук", "яблоко"]);
|
||||
assert_eq!(collate("ЁЛКА", "ёлка", Some(lang)), Ordering::Equal);
|
||||
assert_eq!(heading_letter("Ёлка", Some(lang)).as_deref(), Some("Ё"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_decomposed_letters_match_their_composed_forms() {
|
||||
for (composed, decomposed) in [("ёлка", "е\u{308}лка"), ("Ёлка", "Е\u{308}лка"), ("йод", "и\u{306}од"), ("Йод", "И\u{306}од")]
|
||||
{
|
||||
assert_eq!(collate(composed, decomposed, Some("ru-RU")), Ordering::Equal);
|
||||
assert_eq!(heading_letter(composed, Some("ru")), heading_letter(decomposed, Some("ru")));
|
||||
}
|
||||
assert_eq!(sorted(&["к", "и\u{306}", "и"], "ru"), ["и", "и\u{306}", "к"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_n_tilde_after_n() {
|
||||
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
|
||||
|
||||
@@ -41,6 +41,10 @@ pub struct ShapeExtra {
|
||||
/// The outline dash pattern; absent in older saved documents means solid.
|
||||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||||
pub dash: crate::line_dash::LineDash,
|
||||
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
|
||||
+128
-2
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::para::InlineObject;
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
|
||||
use crate::{Table, table::MAX_TABLE_DEPTH};
|
||||
|
||||
/// A copied piece of a document: blocks, where the first and last paragraphs may be partial.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
@@ -136,6 +137,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
|
||||
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
|
||||
}
|
||||
|
||||
/// The blocks in `t`'s cells, row by row.
|
||||
fn cell_blocks(t: &Table) -> impl DoubleEndedIterator<Item = &Block> {
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without
|
||||
/// recursion: a pasted fragment may be deep).
|
||||
fn table_height(t: &Table) -> usize {
|
||||
let mut most = 1;
|
||||
let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect();
|
||||
while let Some((b, d)) = stack.pop() {
|
||||
if let Block::Table(inner) = b {
|
||||
let d = d.saturating_add(1);
|
||||
most = most.max(d);
|
||||
stack.extend(cell_blocks(inner).map(|cb| (cb, d)));
|
||||
}
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by
|
||||
/// its paragraphs (tables in its cells too, document order), as the DOCX reader does.
|
||||
fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
|
||||
match b {
|
||||
Block::Table(t) if room == 0 => {
|
||||
let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect();
|
||||
while let Some(cb) = stack.pop() {
|
||||
match cb {
|
||||
Block::Para(p) => out.push(Block::Para(p.clone())),
|
||||
Block::Table(inner) => stack.extend(cell_blocks(inner).rev()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Block::Table(mut t) if table_height(&t) > room => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
let mut kept = Vec::new();
|
||||
for cb in std::mem::take(&mut cell.blocks) {
|
||||
fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept);
|
||||
}
|
||||
cell.blocks = kept.into_iter().map(Arc::new).collect();
|
||||
}
|
||||
out.push(Block::Table(t));
|
||||
}
|
||||
b => out.push(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nesting limit when following text boxes inside text boxes.
|
||||
const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
@@ -361,8 +409,14 @@ impl Document {
|
||||
Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 })
|
||||
}
|
||||
|
||||
/// Insert a block before the block at `path` (same container).
|
||||
/// Insert a block before the block at `path` (same container). A table that would nest
|
||||
/// deeper than [`MAX_TABLE_DEPTH`] is refused.
|
||||
pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> {
|
||||
if let Block::Table(t) = &block
|
||||
&& path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH
|
||||
{
|
||||
return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep")));
|
||||
}
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(story, path)?;
|
||||
if i > bl.len() {
|
||||
@@ -736,7 +790,12 @@ impl Document {
|
||||
|
||||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||||
let mut blocks = frag.blocks.clone();
|
||||
// Tables that would nest deeper than the limit here become their paragraphs (#1596).
|
||||
let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth());
|
||||
let mut blocks = Vec::with_capacity(frag.blocks.len());
|
||||
for b in frag.blocks.iter().cloned() {
|
||||
fit_tables(b, room, &mut blocks);
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return Ok(pos.clone());
|
||||
}
|
||||
@@ -941,6 +1000,73 @@ mod tests {
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
|
||||
//! (10000, RTF `\mac`).
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
|
||||
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
|
||||
//! 950 Big5), whose bytes decode as runs rather than one at a time.
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -84,7 +85,7 @@ const CP1255: [u16; 128] = [
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
@@ -286,13 +287,47 @@ pub fn code_page_byte(code_page: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
|
||||
}
|
||||
|
||||
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
|
||||
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
|
||||
/// (Traditional Chinese).
|
||||
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
|
||||
|
||||
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
|
||||
/// its bytes decode as a run, a lead byte together with its trail byte.
|
||||
pub fn is_multibyte_code_page(code_page: u16) -> bool {
|
||||
multibyte_encoding_name(code_page).is_some()
|
||||
}
|
||||
|
||||
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
|
||||
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
|
||||
/// decode one at a time, and anything else.
|
||||
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
|
||||
Some(match code_page {
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
949 => "euc-kr",
|
||||
950 => "big5",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
|
||||
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
|
||||
pub fn is_text_code_page(code_page: u16) -> bool {
|
||||
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the
|
||||
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
|
||||
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
|
||||
/// charsets (the document's code page applies) and symbol fonts.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
254 | 255 => 437,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
@@ -302,6 +337,7 @@ pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -333,6 +369,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1255_holam_haser_for_vav() {
|
||||
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
|
||||
}
|
||||
|
||||
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
|
||||
#[test]
|
||||
fn document_text_in_windows_dos_and_mac_code_pages() {
|
||||
@@ -347,6 +388,25 @@ mod tests {
|
||||
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
|
||||
}
|
||||
|
||||
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
|
||||
/// as runs (a lead byte and its trail byte together), not one at a time.
|
||||
#[test]
|
||||
fn east_asian_code_pages_are_multibyte() {
|
||||
assert_eq!(charset_code_page(128), Some(932), "Japanese");
|
||||
assert_eq!(charset_code_page(129), Some(949), "Korean");
|
||||
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
|
||||
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
|
||||
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
|
||||
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
|
||||
for cp in MULTIBYTE_CODE_PAGES {
|
||||
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
|
||||
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
|
||||
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
|
||||
}
|
||||
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
|
||||
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oem_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
|
||||
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
|
||||
//! `v:fill`).
|
||||
//!
|
||||
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
|
||||
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
|
||||
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
|
||||
//! percent insets (left, top, right, bottom) from the shape's edges.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::props::Rgb;
|
||||
#[cfg(test)]
|
||||
use crate::wordart::MAX_STOPS;
|
||||
use crate::wordart::{GradStop, clamp, sanitized_stops};
|
||||
|
||||
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
|
||||
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Fill {
|
||||
Solid { color: Rgb, transparency: f32 },
|
||||
Gradient(Arc<Gradient>),
|
||||
}
|
||||
|
||||
/// A gradient fill.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub stops: Vec<GradStop>,
|
||||
pub kind: GradientKind,
|
||||
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<[f32; 4]>,
|
||||
/// Turns with the shape (`rotWithShape`).
|
||||
#[serde(default)]
|
||||
pub rot_with_shape: bool,
|
||||
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
|
||||
pub flip: GradientFlip,
|
||||
}
|
||||
|
||||
/// How a gradient runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum GradientKind {
|
||||
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
|
||||
/// angle is scaled with the shape's aspect ratio.
|
||||
Linear {
|
||||
angle: f32,
|
||||
#[serde(default)]
|
||||
scaled: bool,
|
||||
},
|
||||
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
|
||||
Path { shape: PathShape, focus: [f32; 4] },
|
||||
}
|
||||
|
||||
/// A path gradient's outline (`a:path/@path`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathShape {
|
||||
Circle,
|
||||
Rect,
|
||||
Shape,
|
||||
}
|
||||
|
||||
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradientFlip {
|
||||
#[default]
|
||||
None,
|
||||
X,
|
||||
Y,
|
||||
Xy,
|
||||
}
|
||||
|
||||
impl GradientFlip {
|
||||
pub fn is_none(&self) -> bool {
|
||||
*self == GradientFlip::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest rectangle inset kept, percent (hostile files).
|
||||
const MAX_INSET: f32 = 1000.0;
|
||||
|
||||
fn rect(r: [f32; 4]) -> [f32; 4] {
|
||||
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// An opaque solid fill.
|
||||
pub fn solid(color: Rgb) -> Fill {
|
||||
Fill::Solid { color, transparency: 0.0 }
|
||||
}
|
||||
/// The one colour standing for the fill where only one fits: the solid colour, or a
|
||||
/// gradient's middle stop.
|
||||
pub fn main_color(&self) -> Rgb {
|
||||
match self {
|
||||
Fill::Solid { color, .. } => *color,
|
||||
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
|
||||
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
|
||||
/// no fill (`None`).
|
||||
pub fn sanitized(&self) -> Option<Fill> {
|
||||
Some(match self {
|
||||
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
|
||||
Fill::Gradient(g) => {
|
||||
let stops = sanitized_stops(&g.stops);
|
||||
match stops[..] {
|
||||
[] => return None,
|
||||
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
|
||||
_ => {}
|
||||
}
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
|
||||
}
|
||||
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
|
||||
};
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The tagged `.json` form of fills other than opaque solid ones.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
enum Tagged {
|
||||
Solid {
|
||||
color: Rgb,
|
||||
#[serde(default)]
|
||||
transparency: f32,
|
||||
},
|
||||
Gradient(Gradient),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum AnyForm {
|
||||
Plain(Rgb),
|
||||
Tagged(Tagged),
|
||||
}
|
||||
|
||||
impl Serialize for Fill {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
|
||||
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
|
||||
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Fill {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
|
||||
Ok(match AnyForm::deserialize(d)? {
|
||||
AnyForm::Plain(color) => Fill::solid(color),
|
||||
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
|
||||
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
|
||||
GradStop { pos, color, transparency }
|
||||
}
|
||||
|
||||
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
|
||||
let f = Fill::solid(Rgb(1, 2, 3));
|
||||
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
|
||||
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_gradient_fills_round_trip_tagged() {
|
||||
let fills = [
|
||||
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
|
||||
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
|
||||
Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
|
||||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
|
||||
tile: Some([-10.0, 0.0, 0.0, 0.0]),
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
})),
|
||||
];
|
||||
for f in fills {
|
||||
let json = serde_json::to_string(&f).unwrap_or_default();
|
||||
assert!(json.contains("\"type\""), "{json}");
|
||||
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_fills_are_sanitized() {
|
||||
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
|
||||
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
|
||||
assert_eq!(g.stops.len(), MAX_STOPS);
|
||||
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
|
||||
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
|
||||
let path = grad(
|
||||
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
|
||||
);
|
||||
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
|
||||
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
|
||||
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
|
||||
assert_eq!(
|
||||
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
|
||||
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
|
||||
);
|
||||
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
|
||||
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_colour_is_the_middle_stop() {
|
||||
let g = grad(
|
||||
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
|
||||
GradientKind::Linear { angle: 0.0, scaled: false },
|
||||
);
|
||||
assert_eq!(g.main_color(), Rgb(2, 0, 0));
|
||||
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub struct Graphic {
|
||||
pub struct Embedded {
|
||||
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
/// or the `w:object` of an OLE object).
|
||||
/// the `w:object` of an OLE object, or the `w:pict` of a signature line).
|
||||
pub xml: String,
|
||||
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
|
||||
+85
-8
@@ -11,6 +11,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bidi;
|
||||
mod byte_store;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
@@ -22,6 +23,7 @@ pub mod effects;
|
||||
pub mod encoding;
|
||||
pub mod eq;
|
||||
pub mod fields;
|
||||
pub mod fill;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod legacy_form;
|
||||
@@ -631,11 +633,40 @@ pub struct Watermark {
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
pub semitransparent: bool,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true }
|
||||
Watermark {
|
||||
text: "DRAFT".into(),
|
||||
font: styles::BODY_FONT.into(),
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
/// `rotation:315`).
|
||||
pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] {
|
||||
if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) {
|
||||
return [w.min(4000.0), h.min(4000.0)];
|
||||
}
|
||||
let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0));
|
||||
let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = self.text.chars().take(200).count().max(1) as f32;
|
||||
[width, (width / (0.55 * chars)).clamp(12.0, 400.0)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,11 +753,11 @@ pub struct Document {
|
||||
/// The document's own endnote separators and continuation notice.
|
||||
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
|
||||
pub endnote_separators: NoteSeparators,
|
||||
/// Embedded media (images) by key.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
/// Embedded media (images) by key. Native JSON writes each as base64 (`byte_store.rs`).
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
|
||||
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// The document's own building blocks (its glossary document), in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -1079,10 +1110,48 @@ impl Document {
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], &|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
})
|
||||
self.reachable(vec![StoryRef::Body], ¬es_and_boxes)
|
||||
}
|
||||
|
||||
/// The stories a reader sees, in reading order: the body, then the headers and footers in
|
||||
/// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header,
|
||||
/// a first-page or even-page header whose option is off, a linked text box's own text) and
|
||||
/// comment text aren't included.
|
||||
pub fn shown_stories(&self) -> Vec<StoryRef> {
|
||||
let followers: std::collections::BTreeSet<u32> = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect();
|
||||
let follow = |o: &InlineObject| {
|
||||
let mut v = notes_and_boxes(o);
|
||||
v.retain(|(id, _)| !followers.contains(id));
|
||||
v
|
||||
};
|
||||
let mut out = self.reachable(vec![StoryRef::Body], &follow);
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
let mut root_seen = std::collections::BTreeSet::new();
|
||||
let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default());
|
||||
for (_, sec) in self.sections() {
|
||||
// Unset versions are inherited from the previous section.
|
||||
for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] {
|
||||
set.default = own.default.or(set.default);
|
||||
set.first = own.first.or(set.first);
|
||||
set.even = own.even.or(set.even);
|
||||
let first = set.first.filter(|_| sec.title_page);
|
||||
let even = set.even.filter(|_| self.settings.even_odd_headers);
|
||||
// By the first page each one shows on: page 1, then page 2 (even), then page 3.
|
||||
let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] };
|
||||
for id in order.into_iter().flatten() {
|
||||
if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) {
|
||||
roots.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.reachable(roots, &follow) {
|
||||
if seen.insert(st) {
|
||||
out.push(st);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
|
||||
@@ -1452,6 +1521,14 @@ pub fn count_words(s: &str) -> usize {
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
/// The note and text box stories an object shows, for `Document::reachable`.
|
||||
fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
|
||||
match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ 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"];
|
||||
&["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", "es-cu", "es-pe"];
|
||||
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
|
||||
return '.';
|
||||
}
|
||||
@@ -151,6 +151,17 @@ mod tests {
|
||||
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuban_and_peruvian_numbers_use_decimal_points() {
|
||||
for lang in [Some("es-CU"), Some("es-PE")] {
|
||||
assert_eq!(parse_number("1.234", lang), Some(1.234));
|
||||
assert_eq!(parse_number("1,234.50", lang), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, lang), "5.75");
|
||||
}
|
||||
assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
|
||||
+90
-14
@@ -288,8 +288,19 @@ impl Numbering {
|
||||
pub struct Counters {
|
||||
/// Per abstract list: current value per level.
|
||||
state: HashMap<u32, [u32; 9]>,
|
||||
/// Which nums have started (to apply start overrides once).
|
||||
seen: HashMap<u32, bool>,
|
||||
/// Which levels of which nums have started: a num's start override for a level applies
|
||||
/// once, when that level of that num is first used (ECMA-376 Part 1, 17.9.27 startOverride).
|
||||
seen: HashMap<(u32, u8), bool>,
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
struct LevelFlags {
|
||||
started: bool,
|
||||
first: Option<u32>,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
@@ -312,29 +323,36 @@ impl Counters {
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) {
|
||||
let fl = self.flags.entry(abs.id).or_default();
|
||||
// Only this level's override, not the num's others: the counters are shared by every
|
||||
// num of the abstract list, so restarting its other levels here would renumber
|
||||
// paragraphs of the other nums. A heading style can use one num for levels 1-4 and
|
||||
// another, with start overrides on every level, for 5-9; its first level-5 heading
|
||||
// must not restart the chapter count.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry((num, lv as u8)) {
|
||||
e.insert(true);
|
||||
for (l, start) in &n.start_overrides {
|
||||
if let Some(s) = st.get_mut(*l as usize) {
|
||||
*s = start.saturating_sub(1);
|
||||
}
|
||||
if let Some((_, start)) = n.start_overrides.iter().find(|(l, _)| *l as usize == lv)
|
||||
&& let (Some(s), Some(f)) = (st.get_mut(lv), fl.get_mut(lv))
|
||||
{
|
||||
*s = 0;
|
||||
*f = LevelFlags { started: false, first: Some(*start) };
|
||||
}
|
||||
}
|
||||
if let Some(v) = st.get_mut(lv) {
|
||||
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) };
|
||||
if *v == 0 {
|
||||
*v = def.start;
|
||||
}
|
||||
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
|
||||
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
}
|
||||
// Deeper levels restart, unless they never restart or only restart after a level above
|
||||
// this one (Word's lvlRestart).
|
||||
for (d, deeper) in st.iter_mut().enumerate().skip(lv + 1) {
|
||||
for (d, (deeper, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().skip(lv + 1) {
|
||||
let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize));
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
@@ -347,7 +365,8 @@ impl Counters {
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val };
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
@@ -389,6 +408,29 @@ mod tests {
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
|
||||
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
|
||||
// restarting every level (startOverride 1 on 0..8) for levels 5-9, first used deep in
|
||||
// chapter 2. Chapter 3 is still chapter 3, and the deep heading counts from 1.
|
||||
let mut n = Numbering::default();
|
||||
let chapters = n.add_list(ListKind::Legal);
|
||||
let abs = n.abstract_of(chapters).map(|a| a.id).unwrap();
|
||||
let deep = chapters + 1;
|
||||
n.nums.push(Num { id: deep, abstract_id: abs, start_overrides: (0..9).map(|l| (l, 1)).collect(), ..Num::default() });
|
||||
let mut c = Counters::default();
|
||||
let mut l = |num, lv| c.next_label(&n, num, lv).unwrap().0;
|
||||
assert_eq!(l(chapters, 0), "1.");
|
||||
assert_eq!(l(chapters, 0), "2.");
|
||||
assert_eq!(l(chapters, 1), "2.1.");
|
||||
assert_eq!(l(chapters, 2), "2.1.1.");
|
||||
assert_eq!(l(chapters, 3), "2.1.1.1.");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.1.", "the deep num restarts only level 5");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.2.");
|
||||
assert_eq!(l(chapters, 0), "3.", "and not the chapter count");
|
||||
assert_eq!(l(chapters, 1), "3.1.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_restart_only_after_their_restart_level() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -411,6 +453,40 @@ mod tests {
|
||||
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_starting_at_zero_counts_up() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 0;
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..4).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2.", "3."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_of_one_or_zero_is_honoured() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 5;
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 1)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..2).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["1.", "2."]);
|
||||
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 0)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..3).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_labels() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
+24
-11
@@ -77,12 +77,17 @@ pub enum WrapText {
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
match v {
|
||||
"bothSides" => Some(WrapText::BothSides),
|
||||
"left" => Some(WrapText::Left),
|
||||
"right" => Some(WrapText::Right),
|
||||
"largest" => Some(WrapText::Largest),
|
||||
_ => None,
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +225,10 @@ pub struct Float {
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -259,6 +268,9 @@ fn is_zero(v: &f32) -> bool {
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
@@ -440,8 +452,9 @@ pub enum InlineObject {
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
|
||||
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
@@ -459,7 +472,7 @@ pub enum InlineObject {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::props::Rgb>,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
@@ -641,10 +654,10 @@ pub struct GroupChild {
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::props::Rgb>,
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
|
||||
+96
-4
@@ -47,13 +47,38 @@ impl Rgb {
|
||||
}
|
||||
/// Parse `RRGGBB` or `#RRGGBB`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
||||
}
|
||||
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
|
||||
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
|
||||
/// unchanged.
|
||||
pub fn modified(self, name: &str, v: f64) -> Rgb {
|
||||
match name {
|
||||
"lumMod" => adjust_lum(self, |l| l * v),
|
||||
"lumOff" => adjust_lum(self, |l| l + v),
|
||||
"tint" => mix(self, 255.0, v),
|
||||
"shade" => mix(self, 0.0, v),
|
||||
"satMod" => {
|
||||
let (h, sat, l) = to_hsl(self);
|
||||
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
|
||||
}
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
|
||||
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
from_hsl(h, s, l)
|
||||
}
|
||||
/// This colour with its HSL luminance changed by `f`.
|
||||
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(self, f)
|
||||
}
|
||||
pub fn luma(self) -> f32 {
|
||||
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
||||
}
|
||||
@@ -421,6 +446,9 @@ pub struct CharProps {
|
||||
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_bidi: Option<String>,
|
||||
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_ea: Option<String>,
|
||||
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
|
||||
/// Kept for round trip; layout doesn't apply it yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -549,7 +577,7 @@ impl CharProps {
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -560,7 +588,7 @@ impl CharProps {
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
@@ -1298,6 +1326,60 @@ impl CellProps {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1310,6 +1392,16 @@ mod tests {
|
||||
assert_eq!(Rgb::parse("é1234"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_parse_rejects_non_hex() {
|
||||
assert_eq!(Rgb::parse("+1+2+3"), None);
|
||||
assert_eq!(Rgb::parse("-1-2-3"), None);
|
||||
assert_eq!(Rgb::parse("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse("ff00 0"), None);
|
||||
assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_keeps_unset() {
|
||||
let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() };
|
||||
|
||||
@@ -73,8 +73,13 @@ pub struct TableStyleParts {
|
||||
pub band_chr: CharProps,
|
||||
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
|
||||
pub band_borders: Option<Borders>,
|
||||
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
|
||||
/// Rows per band (`w:tblStyleRowBandSize`). Unset (in the whole based-on chain) or zero, the
|
||||
/// row bands are off: Word reads a missing size as zero, where ECMA-376 says one (MS-OE376
|
||||
/// §2.7.5.7, #3415). Our own banded styles set one.
|
||||
pub band_size: Option<u32>,
|
||||
/// Columns per band (`w:tblStyleColBandSize`); unset or zero, the column bands are off, as
|
||||
/// in Word (MS-OI29500 §17.7.6.5).
|
||||
pub col_band_size: Option<u32>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
@@ -179,17 +184,24 @@ pub struct PartSet(u16);
|
||||
|
||||
impl PartSet {
|
||||
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
|
||||
/// in a table with options `look` and row bands of `band_size` rows.
|
||||
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
|
||||
/// in a table with options `look`, row bands of `row_size` rows and column bands of
|
||||
/// `col_size` columns (a size of zero turns those bands off, as in Word).
|
||||
pub fn of_cell(
|
||||
look: &TableLook,
|
||||
(row_size, col_size): (u32, u32),
|
||||
(ri, nrows): (usize, usize),
|
||||
(g, span, ncols): (usize, usize, usize),
|
||||
) -> PartSet {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let size = band_size.clamp(1, 1000) as usize;
|
||||
let row_band = look.banded_rows && !header;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col;
|
||||
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let rows = row_size.min(1000) as usize;
|
||||
let cols = col_size.min(1000) as usize;
|
||||
let row_band = look.banded_rows && !header && rows > 0;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / rows.max(1)).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col && cols > 0;
|
||||
let odd_col = (g.saturating_sub(usize::from(look.first_column)) / cols.max(1)).is_multiple_of(2);
|
||||
let mut bits = TablePart::WholeTable.bit();
|
||||
for (on, p) in [
|
||||
(col_band && odd_col, TablePart::Band1Vert),
|
||||
@@ -270,6 +282,7 @@ impl TableStyleParts {
|
||||
self.header_borders = merge(self.header_borders, patch.header_borders);
|
||||
self.band_borders = merge(self.band_borders, patch.band_borders);
|
||||
self.band_size = patch.band_size.or(self.band_size);
|
||||
self.col_band_size = patch.col_band_size.or(self.col_band_size);
|
||||
self.band_chr.overlay(&patch.band_chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.cell_margins = patch.cell_margins.or(self.cell_margins);
|
||||
@@ -846,6 +859,7 @@ fn p_style() -> Style {
|
||||
/// A few table styles of our own (grid, plain, light/medium accents).
|
||||
fn table_styles() -> Vec<Style> {
|
||||
let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 };
|
||||
// Bands of one row and one column, written out so Word bands them too (#3415).
|
||||
let mk = |id: &str, name: &str, parts: TableStyleParts| Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
@@ -854,7 +868,7 @@ fn table_styles() -> Vec<Style> {
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() },
|
||||
priority: Some(60),
|
||||
builtin: true,
|
||||
table: Some(parts),
|
||||
table: Some(TableStyleParts { band_size: Some(1), col_band_size: Some(1), ..parts }),
|
||||
..Default::default()
|
||||
};
|
||||
let accents = [
|
||||
@@ -979,6 +993,25 @@ mod tests {
|
||||
assert!(c.len() <= 2);
|
||||
}
|
||||
|
||||
/// #3415: a band size of zero (Word's reading of a style without one) turns those bands off.
|
||||
#[test]
|
||||
fn band_size_zero_turns_bands_off() {
|
||||
let look = crate::props::TableLook { banded_columns: true, ..Default::default() };
|
||||
let parts = |sizes, ri, g| PartSet::of_cell(&look, sizes, (ri, 5), (g, 1, 4));
|
||||
assert!(parts((1, 1), 1, 1).contains(TablePart::Band1Horz) && parts((1, 1), 1, 1).contains(TablePart::Band1Vert));
|
||||
assert!(parts((1, 1), 2, 2).contains(TablePart::Band2Horz) && parts((1, 1), 2, 2).contains(TablePart::Band2Vert));
|
||||
for (ri, g) in [(1, 1), (2, 2)] {
|
||||
let p = parts((0, 0), ri, g);
|
||||
for band in [TablePart::Band1Horz, TablePart::Band2Horz, TablePart::Band1Vert, TablePart::Band2Vert] {
|
||||
assert!(!p.contains(band), "{band:?} at row {ri}, column {g}");
|
||||
}
|
||||
}
|
||||
// Our banded styles say so, so Word bands them too.
|
||||
let s = StyleSheet::builtin();
|
||||
let t = s.table_style("GridTable4AccentBlue").map(|t| t.parts);
|
||||
assert_eq!(t.map(|t| (t.band_size, t.col_band_size)), Some((Some(1), Some(1))));
|
||||
}
|
||||
|
||||
/// A table style based on another inherits what it doesn't set itself; borders merge edge by
|
||||
/// edge.
|
||||
#[test]
|
||||
|
||||
+30
-3
@@ -43,6 +43,13 @@ pub struct Row {
|
||||
pub controls: ControlWrap,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Grid column where the row's first cell starts: the columns `w:gridBefore` skips.
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Table {
|
||||
#[serde(default)]
|
||||
@@ -58,6 +65,10 @@ pub struct Table {
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
|
||||
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
|
||||
/// when it is saved, copied or dropped (#1596).
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
@@ -86,18 +97,22 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::<usize>()).max().unwrap_or(0))
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Grid column where cell `c` of row `r` starts.
|
||||
pub fn grid_col(&self, r: usize, c: usize) -> usize {
|
||||
self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0)
|
||||
self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::<usize>()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cell index in row `r` that covers grid column `g`.
|
||||
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
|
||||
let row = self.rows.get(r)?;
|
||||
let mut x = 0;
|
||||
let mut x = row.first_grid();
|
||||
if g < x {
|
||||
return None;
|
||||
}
|
||||
for (i, c) in row.cells.iter().enumerate() {
|
||||
if g < x + c.span() {
|
||||
return Some(i);
|
||||
@@ -495,6 +510,18 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
assert_eq!(t.grid_col(1, 0), 1);
|
||||
assert_eq!(t.grid_col(1, 1), 2);
|
||||
assert_eq!(t.cell_at_grid(1, 0), None);
|
||||
assert_eq!(t.cell_at_grid(1, 2), Some(1));
|
||||
assert_eq!(t.cols(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_table_shape() {
|
||||
let t = Table::new(3, 4, 468.0);
|
||||
|
||||
@@ -100,10 +100,22 @@ impl Default for Reflection {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
pub(crate) fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
|
||||
}
|
||||
|
||||
/// At most [`MAX_STOPS`] stops, positions and transparencies 0–100%, in order (text and shape
|
||||
/// gradients).
|
||||
pub(crate) fn sanitized_stops(stops: &[GradStop]) -> Vec<GradStop> {
|
||||
let mut s: Vec<GradStop> = stops
|
||||
.iter()
|
||||
.take(MAX_STOPS)
|
||||
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
|
||||
.collect();
|
||||
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
s
|
||||
}
|
||||
|
||||
impl TextFill {
|
||||
/// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no
|
||||
/// gradient (`None` back).
|
||||
@@ -112,15 +124,10 @@ impl TextFill {
|
||||
TextFill::None => TextFill::None,
|
||||
TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
|
||||
TextFill::Gradient { stops, angle } => {
|
||||
let mut s: Vec<GradStop> = stops
|
||||
.iter()
|
||||
.take(MAX_STOPS)
|
||||
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
|
||||
.collect();
|
||||
let s = sanitized_stops(stops);
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ miniz_oxide = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -201,7 +202,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) };
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Sprms → model properties, and style-chain resolution.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
|
||||
|
||||
use crate::sprm::{self, Prl, Toggle};
|
||||
@@ -42,6 +44,18 @@ const P_KEEP: u16 = 0x2405; // keep lines together
|
||||
const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next
|
||||
const P_PAGE_BREAK_BEFORE: u16 = 0x2407;
|
||||
const P_OUTLVL: u16 = 0x2640;
|
||||
// Paragraph borders ([MS-DOC] §2.6.2). The 80 forms carry a Brc80 (palette colour), the
|
||||
// newer ones a BrcOperand: cb(1) + Brc(8, with a full COLORREF).
|
||||
const P_BRC_TOP80: u16 = 0x6424;
|
||||
const P_BRC_LEFT80: u16 = 0x6425;
|
||||
const P_BRC_BOTTOM80: u16 = 0x6426;
|
||||
const P_BRC_RIGHT80: u16 = 0x6427;
|
||||
const P_BRC_BETWEEN80: u16 = 0x6428;
|
||||
const P_BRC_TOP: u16 = 0xC64E;
|
||||
const P_BRC_LEFT: u16 = 0xC64F;
|
||||
const P_BRC_BOTTOM: u16 = 0xC650;
|
||||
const P_BRC_RIGHT: u16 = 0xC651;
|
||||
const P_BRC_BETWEEN: u16 = 0xC652;
|
||||
|
||||
/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic.
|
||||
const ICO: [Rgb; 17] = [
|
||||
@@ -93,6 +107,69 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
ICO.get(i as usize).copied()
|
||||
}
|
||||
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
|
||||
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
|
||||
/// reader maps them.
|
||||
fn brc_style(t: u8) -> BorderStyle {
|
||||
match t {
|
||||
0x00 => BorderStyle::None,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x06 => BorderStyle::Dotted,
|
||||
0x07 | 0x16 => BorderStyle::Dashed,
|
||||
0x08 | 0x09 => BorderStyle::DotDash,
|
||||
0x0A => BorderStyle::Triple,
|
||||
0x14 | 0x15 => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
|
||||
/// 1-point steps for page borders, which never reach a paragraph property).
|
||||
fn brc_width(t: u8, dpt: u8) -> f32 {
|
||||
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
|
||||
}
|
||||
|
||||
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
|
||||
/// truncated operand is ignored.
|
||||
fn brc80(op: &[u8]) -> Option<Border> {
|
||||
let [dpt, ty, ico_index, tail, ..] = op else { return None };
|
||||
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
|
||||
return Some(Border::default());
|
||||
}
|
||||
Some(Border { style: brc_style(*ty), width: brc_width(*ty, *dpt), color: ico(*ico_index), space: f32::from(tail & 0x1F) })
|
||||
}
|
||||
|
||||
/// One border from a `BrcOperand` ([MS-DOC] §2.9.21): cb(1, MUST be 8) + Brc(8).
|
||||
fn brc(op: &[u8]) -> Option<Border> {
|
||||
if op.first() != Some(&8) {
|
||||
return None;
|
||||
}
|
||||
let b = op.get(1..9)?;
|
||||
if b.iter().all(|x| *x == 0xFF) {
|
||||
return Some(Border::default());
|
||||
}
|
||||
let color = match b.get(..4) {
|
||||
Some([0, 0, 0, 0]) => None, // automatic
|
||||
Some(px) => Some(Rgb(px[0], px[1], px[2])),
|
||||
None => None,
|
||||
};
|
||||
Some(Border { style: brc_style(b[5]), width: brc_width(b[5], b[4]), color, space: f32::from(b[6] & 0x1F) })
|
||||
}
|
||||
|
||||
/// Put a decoded border on the side its sprm names (`between` = the border between this
|
||||
/// paragraph and the next).
|
||||
fn set_border(p: &mut ParaProps, op: u16, b: Border) {
|
||||
let borders = p.borders.get_or_insert_with(Borders::default);
|
||||
let slot = match op {
|
||||
P_BRC_TOP80 | P_BRC_TOP => &mut borders.top,
|
||||
P_BRC_LEFT80 | P_BRC_LEFT => &mut borders.left,
|
||||
P_BRC_BOTTOM80 | P_BRC_BOTTOM => &mut borders.bottom,
|
||||
P_BRC_RIGHT80 | P_BRC_RIGHT => &mut borders.right,
|
||||
_ => &mut borders.between,
|
||||
};
|
||||
*slot = Some(b);
|
||||
}
|
||||
|
||||
fn u16_of(p: &Prl) -> Option<u16> {
|
||||
match p.operand {
|
||||
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
|
||||
@@ -324,6 +401,16 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
p.outline_level = Some(l.min(8));
|
||||
}
|
||||
}
|
||||
P_BRC_TOP80 | P_BRC_LEFT80 | P_BRC_BOTTOM80 | P_BRC_RIGHT80 | P_BRC_BETWEEN80 => {
|
||||
if let Some(b) = brc80(prl.operand) {
|
||||
set_border(p, prl.op, b);
|
||||
}
|
||||
}
|
||||
P_BRC_TOP | P_BRC_LEFT | P_BRC_BOTTOM | P_BRC_RIGHT | P_BRC_BETWEEN => {
|
||||
if let Some(b) = brc(prl.operand) {
|
||||
set_border(p, prl.op, b);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-17
@@ -51,6 +51,9 @@ const MAX_PARTS: usize = 50_000;
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
|
||||
const C_PIC_LOCATION: u16 = 0x6A03;
|
||||
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
|
||||
/// text stream holds the placeholder U+0028 at its position.
|
||||
const C_SYMBOL: u16 = 0x6A09;
|
||||
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
|
||||
const P_ILVL: u16 = 0x260A;
|
||||
const P_ILFO: u16 = 0x460B;
|
||||
@@ -581,30 +584,80 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
/// (`#bookmark` for internal links); `None` when not a hyperlink.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
let rest = instr.strip_prefix("HYPERLINK").or_else(|| instr.strip_prefix("hyperlink"))?;
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
let t = instr.trim();
|
||||
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
|
||||
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut quoted = None;
|
||||
let mut local = None;
|
||||
let mut tokens = rest.split_whitespace().peekable();
|
||||
while let Some(tok) = tokens.next() {
|
||||
match tok {
|
||||
"\\l" => {
|
||||
local = Some(tokens.next().unwrap_or_default().trim_matches('"').to_string());
|
||||
|
||||
let mut url: Option<String> = None;
|
||||
let mut local: Option<String> = None;
|
||||
let mut switch: Option<&str> = None;
|
||||
|
||||
let mut i = 0;
|
||||
let bytes = rest.as_bytes();
|
||||
while i < bytes.len() {
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[i] == b'\\' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
|
||||
i += 1;
|
||||
}
|
||||
"\\o" | "\\m" | "\\n" | "\\t" => {
|
||||
let _ = tokens.next();
|
||||
switch = Some(rest.get(start..i).unwrap_or(""));
|
||||
continue;
|
||||
}
|
||||
let arg = if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'"' {
|
||||
i += 1;
|
||||
}
|
||||
let val = rest.get(start..i).unwrap_or("");
|
||||
if i < bytes.len() && bytes[i] == b'"' {
|
||||
i += 1;
|
||||
}
|
||||
val.to_string()
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
rest.get(start..i).unwrap_or("").to_string()
|
||||
};
|
||||
let clean_arg = arg.replace("%22", "\"");
|
||||
match switch.take() {
|
||||
Some("\\l") => local = Some(clean_arg),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if url.is_none() {
|
||||
url = Some(clean_arg);
|
||||
}
|
||||
}
|
||||
_ if quoted.is_none() => quoted = Some(tok.trim_matches('"').to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(match local {
|
||||
Some(name) if !name.is_empty() => format!("#{name}"),
|
||||
_ => quoted.unwrap_or_default(),
|
||||
})
|
||||
|
||||
match (url, local) {
|
||||
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
|
||||
}
|
||||
(Some(u), _) if !u.is_empty() => Some(u),
|
||||
(None, Some(l)) if !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
Some(format!("#{frag}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
@@ -620,6 +673,19 @@ fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `sprmCSymbol` from a CHPX grpprl: the font table index and the character code of a symbol
|
||||
/// character ([MS-DOC] §2.6.1, CSymbolOperand).
|
||||
fn symbol_operand(chpx: &[u8]) -> Option<(u16, u16)> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
if prl.op == C_SYMBOL
|
||||
&& let [f0, f1, c0, c1, ..] = prl.operand
|
||||
{
|
||||
return Some((u16::from_le_bytes([*f0, *f1]), u16::from_le_bytes([*c0, *c1])));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the
|
||||
/// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1)
|
||||
/// is the negation of one and keeps the paragraph's own indents, which we do by leaving the
|
||||
@@ -649,6 +715,19 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let mut c = c;
|
||||
if c == '('
|
||||
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
|
||||
{
|
||||
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
|
||||
props.font = Some(font.clone());
|
||||
}
|
||||
if let Some(sym) = char::from_u32(u32::from(xchar)) {
|
||||
c = sym;
|
||||
}
|
||||
}
|
||||
match field {
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
|
||||
@@ -87,20 +87,8 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
})
|
||||
}
|
||||
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None`
|
||||
/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch.
|
||||
fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
if kind != 64 {
|
||||
@@ -114,14 +102,34 @@ fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||
}
|
||||
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
||||
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
||||
let unix_secs = unix_100ns / 10_000_000;
|
||||
let days = (unix_secs / 86_400) as i64;
|
||||
let secs_of_day = (unix_secs % 86_400) as u32;
|
||||
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||
let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?;
|
||||
let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
|
||||
let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60);
|
||||
let (y, m, d) = civil(days);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter
|
||||
/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar
|
||||
/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole
|
||||
/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the
|
||||
/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
let count = u32_at(value, 4)? as usize;
|
||||
@@ -151,3 +159,35 @@ fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::filetime_iso;
|
||||
|
||||
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
|
||||
/// to what the old hand-rolled `civil` produced.
|
||||
fn value(ft: u64) -> Vec<u8> {
|
||||
let mut v = 64u32.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes());
|
||||
v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetime_to_iso() {
|
||||
// 2020-01-01T00:00:00Z and the same day at 13:45:09.
|
||||
assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z"));
|
||||
assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z"));
|
||||
// Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add).
|
||||
assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z"));
|
||||
// The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year
|
||||
// matters) and the old `civil` converted it without ever dropping to the epoch. chrono's
|
||||
// own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds
|
||||
// ~1.83e12 — but the 400-year cycle keeps the result exact regardless.
|
||||
assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z"));
|
||||
// Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic.
|
||||
assert_eq!(filetime_iso(&value(0)), None);
|
||||
assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(filetime_iso(&value(1)), None);
|
||||
}
|
||||
}
|
||||
@@ -856,7 +856,7 @@ fn struct_doc(spec: &StructSpec) -> Vec<u8> {
|
||||
let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]);
|
||||
let stsh_at = table.len();
|
||||
table.extend_from_slice(&stsh);
|
||||
let ffn = ffn_bytes(&["Times New Roman"]);
|
||||
let ffn = ffn_bytes(&["Times New Roman", "Wingdings"]);
|
||||
let ffn_at = table.len();
|
||||
table.extend_from_slice(&ffn);
|
||||
let lst_at = table.len();
|
||||
@@ -1354,6 +1354,22 @@ fn fields_and_hyperlinks() {
|
||||
assert_eq!(result, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_target_sublocation_and_quotes() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into()));
|
||||
}
|
||||
|
||||
/// A field whose name only starts with HYPERLINK is not a hyperlink.
|
||||
#[test]
|
||||
fn hyperlink_target_needs_the_whole_field_name() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None);
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_become_objects() {
|
||||
let text = "Hello world\r";
|
||||
@@ -2330,3 +2346,49 @@ fn sprm_t_cell_width_twips_and_percentage() {
|
||||
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #1767: symbol runs and paragraph borders.
|
||||
|
||||
/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus
|
||||
/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code
|
||||
/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(".
|
||||
#[test]
|
||||
fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
|
||||
let text = "(\r";
|
||||
// ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71.
|
||||
let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat();
|
||||
let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder");
|
||||
assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table");
|
||||
}
|
||||
|
||||
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
|
||||
#[test]
|
||||
fn paragraph_borders_read_from_both_forms() {
|
||||
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
|
||||
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
|
||||
let text = "boxed\r\r";
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
papx_entries: vec![
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc)])),
|
||||
],
|
||||
..structured_spec(text, text.chars().count() as u32)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
|
||||
let b = p0.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
|
||||
let top = p1.props.borders.expect("borders").top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! specification, with tiny pictures generated in code. No file produced by Word is used.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -173,10 +174,10 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects);
|
||||
let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0));
|
||||
assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0));
|
||||
assert_eq!((fill.clone(), *stroke, *stroke_width), (Some(Fill::solid(Rgb(255, 0, 0))), Some(Rgb(0, 0, 255)), 2.0));
|
||||
assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph));
|
||||
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
assert_eq!((*kind, fill.clone(), float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
|
||||
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
|
||||
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
|
||||
@@ -52,6 +52,22 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
|
||||
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
|
||||
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
|
||||
/// without reading the whole package.
|
||||
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(package).ok()?;
|
||||
let part = zip.by_name("docProps/core.xml").ok()?;
|
||||
let mut xml = Vec::new();
|
||||
part.take(max_part).read_to_end(&mut xml).ok()?;
|
||||
let root = xml::parse(&xml).ok()?;
|
||||
let title = root.child("dc:title")?.text();
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| title.to_string())
|
||||
}
|
||||
|
||||
/// What [`DocxError::PasswordRequired`] says.
|
||||
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
|
||||
/// What [`DocxError::WrongPassword`] says.
|
||||
|
||||
+233
-22
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
|
||||
//! charts are drawn from the first supported chart type of the plot area, with their values as
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). Radar charts (standard, with markers or
|
||||
//! filled) draw as spokes, concentric grid polygons and one polygon per series. A chart of only other types (stock, bubble…)
|
||||
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
@@ -23,6 +24,10 @@ const MAX_POINTS: usize = 10_000;
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
/// Longest series name or category label kept.
|
||||
const MAX_LABEL: usize = 200;
|
||||
/// Most categories (spokes) of a radar chart.
|
||||
const MAX_SPOKES: usize = 100;
|
||||
/// Most series of a radar chart.
|
||||
const MAX_RADAR_SERIES: usize = 50;
|
||||
/// Most axis ticks drawn.
|
||||
const MAX_TICKS: usize = 20;
|
||||
/// Default text size, points.
|
||||
@@ -75,6 +80,7 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
|
||||
}
|
||||
match chart.kind {
|
||||
Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area),
|
||||
Kind::Radar => radar(&mut out, &chart, area),
|
||||
_ => cartesian(&mut out, &chart, area),
|
||||
}
|
||||
out
|
||||
@@ -87,7 +93,7 @@ fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
|
||||
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
|
||||
}
|
||||
|
||||
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
|
||||
/// A chart whose plot area holds only chart types WordCraft doesn't draw (stock, bubble…): its
|
||||
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
|
||||
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
|
||||
/// to draw.
|
||||
@@ -114,7 +120,6 @@ fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h:
|
||||
/// What a placeholder calls a chart type element WordCraft doesn't draw.
|
||||
fn unsupported_name(name: &str) -> &'static str {
|
||||
match name {
|
||||
"c:radarChart" => "Radar chart",
|
||||
"c:stockChart" => "Stock chart",
|
||||
"c:bubbleChart" => "Bubble chart",
|
||||
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
|
||||
@@ -144,6 +149,7 @@ enum Kind {
|
||||
Pie,
|
||||
Doughnut,
|
||||
Scatter,
|
||||
Radar,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -155,6 +161,7 @@ impl Kind {
|
||||
"c:pieChart" => Kind::Pie,
|
||||
"c:doughnutChart" => Kind::Doughnut,
|
||||
"c:scatterChart" => Kind::Scatter,
|
||||
"c:radarChart" => Kind::Radar,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -221,6 +228,10 @@ struct Chart {
|
||||
/// Doughnut hole, fraction of the outer radius.
|
||||
hole: f32,
|
||||
first_angle: f64,
|
||||
/// Radar: series polygons are filled (`c:radarStyle` = `filled`).
|
||||
radar_fill: bool,
|
||||
/// Radar: points carry markers (`c:radarStyle` = `marker`).
|
||||
radar_marks: bool,
|
||||
vary: bool,
|
||||
/// Number of categories (scatter: points per series at most).
|
||||
n: usize,
|
||||
@@ -253,7 +264,9 @@ impl Chart {
|
||||
return None;
|
||||
}
|
||||
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
|
||||
let n = if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
||||
let n = if kind == Kind::Radar {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES)
|
||||
} else if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
||||
series.first().map_or(0, |s| s.values.len()).max(cats.len())
|
||||
} else {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
|
||||
@@ -262,6 +275,9 @@ impl Chart {
|
||||
if let Some(keep) = MAX_CELLS.checked_div(n) {
|
||||
series.truncate(keep.max(1));
|
||||
}
|
||||
if kind == Kind::Radar {
|
||||
series.truncate(MAX_RADAR_SERIES);
|
||||
}
|
||||
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
|
||||
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
|
||||
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
|
||||
@@ -269,6 +285,7 @@ impl Chart {
|
||||
};
|
||||
let mut cats = cats;
|
||||
cats.resize(n, String::new());
|
||||
let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val"));
|
||||
let type_labels = show_values(ty.child("c:dLbls"));
|
||||
for s in &mut series {
|
||||
s.values.resize(n, None);
|
||||
@@ -296,6 +313,8 @@ impl Chart {
|
||||
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
|
||||
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
|
||||
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
|
||||
radar_fill: radar_style == Some("filled"),
|
||||
radar_marks: radar_style == Some("marker"),
|
||||
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
|
||||
n,
|
||||
cats,
|
||||
@@ -803,18 +822,27 @@ fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
|
||||
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
||||
}
|
||||
|
||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's
|
||||
/// civil-from-days algorithm).
|
||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
|
||||
/// day number). Serial `n` is `n - 25_569` days from 1970-01-01.
|
||||
///
|
||||
/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats
|
||||
/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar
|
||||
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
|
||||
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n`
|
||||
/// is CE day `n - 25_569 + 719_163`.
|
||||
fn civil(n: i64) -> (i64, u32, u32) {
|
||||
let z = n - 25_569 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
use chrono::Datelike;
|
||||
// CE day of serial `n`, in i128 so no `n` overflows.
|
||||
let from_ce = i128::from(n) - 25_569 + 719_163;
|
||||
let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
|
||||
match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(date) => {
|
||||
let y = i128::from(date.year()) + cycles * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
|
||||
}
|
||||
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
|
||||
None => (1900, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
@@ -1324,7 +1352,7 @@ fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
Kind::Line => lines(out, c, &g, &mut labels),
|
||||
Kind::Area => areas(out, c, &g, &mut labels),
|
||||
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
|
||||
Kind::Pie | Kind::Doughnut => {}
|
||||
Kind::Pie | Kind::Doughnut | Kind::Radar => {}
|
||||
}
|
||||
draw_axes(out, c, &g);
|
||||
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
|
||||
@@ -1621,6 +1649,94 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
draw_data_labels(out, &labels, c.text_size, false);
|
||||
}
|
||||
|
||||
/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value
|
||||
/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the
|
||||
/// spoke ends. The lowest tick is the centre.
|
||||
fn radar(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
const MARKER: f32 = 5.0;
|
||||
if c.n == 0 {
|
||||
return;
|
||||
}
|
||||
let fs = c.text_size;
|
||||
let lh = fs * LINE;
|
||||
let labelled = c.cats.iter().any(|s| !s.is_empty());
|
||||
let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 };
|
||||
let vert = if labelled { lh + GAP } else { 0.0 };
|
||||
let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64;
|
||||
if r.is_nan() || r < 8.0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
|
||||
let vs = Axes::of(c).vs;
|
||||
let span = vs.hi - vs.lo;
|
||||
let angle = |i: usize| i as f64 / c.n as f64 * TAU;
|
||||
let at = |v: f64, i: usize| {
|
||||
let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
polar(cx, cy, r * t, angle(i))
|
||||
};
|
||||
let ticks = vs.ticks();
|
||||
if c.val.grid {
|
||||
for t in ticks.iter().skip(1) {
|
||||
let mut segs: Vec<PathSeg> = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
stroke(out, segs, GRID, 0.75);
|
||||
}
|
||||
}
|
||||
for i in 0..c.n {
|
||||
let end = polar(cx, cy, r, angle(i));
|
||||
stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75);
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let col = c.series_colour(j);
|
||||
// A missing value counts as the centre, as Word draws it.
|
||||
let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect();
|
||||
let mut segs: Vec<PathSeg> = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
if c.radar_fill {
|
||||
fill(out, segs.clone(), col);
|
||||
}
|
||||
stroke(out, segs, col, s.line_w);
|
||||
for (i, p) in ring.iter().enumerate() {
|
||||
if c.radar_marks && s.values.get(i).copied().flatten().is_some() {
|
||||
let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER];
|
||||
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
if let Some(text) = label_of(s, i) {
|
||||
labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
draw_data_labels(out, &labels, fs, false);
|
||||
if !c.val.deleted {
|
||||
for t in &ticks {
|
||||
let y = at(*t, 0).1 as f32;
|
||||
let s = format_value(*t, &c.val_fmt, vs.step);
|
||||
let w = text_w(&s, fs) + 4.0;
|
||||
text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false);
|
||||
}
|
||||
}
|
||||
if labelled {
|
||||
for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
|
||||
let a = angle(i);
|
||||
let (x, y) = polar(cx, cy, r + GAP as f64, a);
|
||||
let (x, y) = (x as f32, y as f32);
|
||||
let w = side - GAP;
|
||||
let txt = clip_text(s, w, fs);
|
||||
let (sx, sy) = (a.sin(), a.cos());
|
||||
let (rect, align) = if sx > 0.2 {
|
||||
(Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left)
|
||||
} else if sx < -0.2 {
|
||||
(Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right)
|
||||
} else {
|
||||
let cw = (text_w(&txt, fs) + 4.0).max(1.0);
|
||||
(Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center)
|
||||
};
|
||||
text(out, rect, &txt, fs, align, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
|
||||
/// in the series' number format.
|
||||
fn label_of(s: &Series, i: usize) -> Option<String> {
|
||||
@@ -1849,21 +1965,104 @@ mod tests {
|
||||
fn unsupported_chart_types_draw_a_placeholder() {
|
||||
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
|
||||
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
|
||||
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
|
||||
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {radar:?}");
|
||||
let x =
|
||||
format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
|
||||
let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {bubble:?}");
|
||||
|
||||
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
|
||||
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
|
||||
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
|
||||
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
let combo = chart_items(&space(&format!("<c:stockChart>{ser}</c:stockChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
|
||||
}
|
||||
|
||||
fn radar_chart(style: &str, sers: &[String]) -> Vec<GraphicItem> {
|
||||
let plot = format!(r#"<c:radarChart><c:radarStyle val="{style}"/>{}</c:radarChart><c:valAx><c:majorGridlines/></c:valAx>"#, sers.concat());
|
||||
chart_items(&space(&plot), &theme(), 300.0, 220.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_draw_spokes_grid_and_series_polygons() {
|
||||
let cats = ["Speed", "Power", "Range", "Armor", "Luck"];
|
||||
let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])];
|
||||
let std = radar_chart("standard", &sers);
|
||||
assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}");
|
||||
for c in cats {
|
||||
assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std));
|
||||
}
|
||||
let stroked = |items: &[GraphicItem], col: Rgb| {
|
||||
items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count()
|
||||
};
|
||||
assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}");
|
||||
assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1);
|
||||
assert!(filled(&std).is_empty(), "standard radar is not filled");
|
||||
assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })));
|
||||
// 5 spokes (2 segs each) plus grid pentagons.
|
||||
let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, 5);
|
||||
assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6)));
|
||||
|
||||
let marked = radar_chart("marker", &sers);
|
||||
let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count();
|
||||
assert_eq!(dots, 10, "a marker per point");
|
||||
|
||||
let solid = radar_chart("filled", &sers);
|
||||
let f = filled(&solid);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_show_a_legend() {
|
||||
let cats = ["a", "b", "c"];
|
||||
let plot = format!("<c:radarChart>{}</c:radarChart>", ser(0, "Team", None, &cats, &["1", "2", "3"]));
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_radar_charts_never_panic() {
|
||||
// No categories and no values.
|
||||
let empty =
|
||||
r#"<c:ser><c:cat><c:strLit><c:ptCount val="0"/></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="0"/></c:numLit></c:val></c:ser>"#;
|
||||
let _ = chart_items(&space(&format!("<c:radarChart>{empty}</c:radarChart>")), &theme(), 300.0, 200.0);
|
||||
let none = chart_items(&space("<c:radarChart/>"), &theme(), 300.0, 200.0);
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
// One category: a degenerate polygon, nothing to crash on; two as well.
|
||||
for n in 1..=2 {
|
||||
let cats: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["5"; n];
|
||||
let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]);
|
||||
assert!(texts(&items).contains(&"c0"), "{items:?}");
|
||||
}
|
||||
// NaN, infinities, junk and a tiny frame.
|
||||
let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]);
|
||||
let _ = radar_chart("filled", &[junk]);
|
||||
let small = chart_items(
|
||||
&space(&format!("<c:radarChart>{}</c:radarChart>", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))),
|
||||
&theme(),
|
||||
20.0,
|
||||
20.0,
|
||||
);
|
||||
let _ = small;
|
||||
// Caps: thousands of categories and series stay bounded.
|
||||
let cats: Vec<String> = (0..5000).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["1"; 5000];
|
||||
let many: Vec<String> = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect();
|
||||
let items = radar_chart("marker", &many);
|
||||
let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count();
|
||||
assert!(polygons <= MAX_RADAR_SERIES, "{polygons}");
|
||||
let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, MAX_SPOKES);
|
||||
}
|
||||
|
||||
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
|
||||
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
let x = format!(
|
||||
@@ -1975,6 +2174,18 @@ mod tests {
|
||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_is_exact_beyond_chronos_range() {
|
||||
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||
assert_eq!(civil(1), (1899, 12, 31));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
|
||||
// the epoch; values from the pre-chrono proleptic Gregorian formula.
|
||||
assert_eq!(civil(100_000_000), (275_690, 9, 11));
|
||||
assert_eq!(civil(-100_000_000), (-271_891, 4, 18));
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (civil(i64::MAX), civil(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_formats_keep_literal_text_and_currency() {
|
||||
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the
|
||||
//! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::THEME_COLORS;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
use crate::units::int;
|
||||
use crate::xml::El;
|
||||
@@ -35,11 +39,24 @@ fn is_color(e: &El) -> bool {
|
||||
}
|
||||
|
||||
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
|
||||
/// `lumMod` / `lumOff` / `tint` / `shade` applied in document order.
|
||||
/// `lumMod` / `lumOff` / `tint` / `shade` / `satMod` applied in document order.
|
||||
pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
color_alpha(e, theme, None).map(|(c, _)| c)
|
||||
}
|
||||
|
||||
/// [`color`] and its transparency, percent (`a:alpha` is opacity, so 30% opaque is 70%
|
||||
/// transparent). `ph` is the colour and transparency `phClr` stands for (in a theme's fill
|
||||
/// styles: the colour of the shape's style reference).
|
||||
pub(crate) fn color_alpha(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<(Rgb, f32)> {
|
||||
let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? };
|
||||
let mut opacity = 100.0;
|
||||
let base = match c.name.as_str() {
|
||||
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
|
||||
"a:schemeClr" if c.attr("val") == Some("phClr") => {
|
||||
let (rgb, t) = ph?;
|
||||
opacity -= t;
|
||||
rgb
|
||||
}
|
||||
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
|
||||
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
|
||||
Some(rgb) => rgb,
|
||||
@@ -58,7 +75,7 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
"a:hslClr" => {
|
||||
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
|
||||
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
|
||||
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
|
||||
Rgb::from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
@@ -66,14 +83,68 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
let mut rgb = base;
|
||||
for m in c.els() {
|
||||
rgb = match m.name.as_str() {
|
||||
"a:lumMod" => adjust_lum(rgb, |l| l * val(m)),
|
||||
"a:lumOff" => adjust_lum(rgb, |l| l + val(m)),
|
||||
"a:tint" => mix(rgb, 255.0, val(m)),
|
||||
"a:shade" => mix(rgb, 0.0, val(m)),
|
||||
n @ ("a:lumMod" | "a:lumOff" | "a:tint" | "a:shade" | "a:satMod") => modified(rgb, n.trim_start_matches("a:"), val(m)),
|
||||
"a:alpha" => {
|
||||
opacity = val(m) as f32 * 100.0;
|
||||
rgb
|
||||
}
|
||||
"a:alphaMod" => {
|
||||
opacity *= val(m) as f32;
|
||||
rgb
|
||||
}
|
||||
"a:alphaOff" => {
|
||||
opacity += val(m) as f32 * 100.0;
|
||||
rgb
|
||||
}
|
||||
_ => rgb,
|
||||
};
|
||||
}
|
||||
Some(rgb)
|
||||
Some((rgb, (100.0 - opacity).clamp(0.0, 100.0)))
|
||||
}
|
||||
|
||||
/// Fill element `e` (`a:solidFill`, `a:gradFill`; a pattern as its foreground colour) as a
|
||||
/// shape's fill; `None` for no fill, picture and group fills. `ph` as in [`color_alpha`].
|
||||
pub(crate) fn fill(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<Fill> {
|
||||
let solid = |c: Option<&El>| c.and_then(|c| color_alpha(c, theme, ph)).map(|(color, transparency)| Fill::Solid { color, transparency });
|
||||
match e.name.as_str() {
|
||||
"a:solidFill" => solid(Some(e)),
|
||||
"a:pattFill" => solid(e.child("a:fgClr")),
|
||||
"a:gradFill" => {
|
||||
let pct = |v: Option<&str>| v.and_then(int).map_or(0.0, |v| v.clamp(-100_000_000, 100_000_000) as f32 / 1000.0);
|
||||
let rect = |r: Option<&El>| r.map_or([0.0; 4], |r| ["l", "t", "r", "b"].map(|k| pct(r.attr(k))));
|
||||
let stops = e
|
||||
.child("a:gsLst")
|
||||
.into_iter()
|
||||
.flat_map(|l| l.children("a:gs"))
|
||||
.take(MAX_STOPS * 4)
|
||||
.filter_map(|g| color_alpha(g, theme, ph).map(|(color, transparency)| GradStop { pos: pct(g.attr("pos")), color, transparency }))
|
||||
.collect();
|
||||
let on = |v: Option<&str>| v.is_some_and(|v| v == "1" || v == "true");
|
||||
let kind = match (e.child("a:path"), e.child("a:lin")) {
|
||||
(Some(p), _) => {
|
||||
let shape = match p.attr("path") {
|
||||
Some("rect") => PathShape::Rect,
|
||||
Some("shape") => PathShape::Shape,
|
||||
_ => PathShape::Circle,
|
||||
};
|
||||
GradientKind::Path { shape, focus: rect(p.child("a:fillToRect")) }
|
||||
}
|
||||
(None, l) => GradientKind::Linear {
|
||||
angle: l.and_then(|l| l.attr("ang")).and_then(int).map_or(0.0, |a| (a % 21_600_000) as f32 / 60_000.0),
|
||||
scaled: on(l.and_then(|l| l.attr("scaled"))),
|
||||
},
|
||||
};
|
||||
let flip = match e.attr("flip") {
|
||||
Some("x") => GradientFlip::X,
|
||||
Some("y") => GradientFlip::Y,
|
||||
Some("xy") => GradientFlip::Xy,
|
||||
_ => GradientFlip::None,
|
||||
};
|
||||
let tile = e.child("a:tileRect").map(|r| rect(Some(r)));
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile, rot_with_shape: on(e.attr("rotWithShape")), flip })).sanitized()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
|
||||
@@ -159,58 +230,14 @@ pub(crate) fn lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(c, f)
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
/// `c` with the DrawingML colour modifier `name` at `v` (see [`Rgb::modified`]).
|
||||
fn modified(c: Rgb, name: &str, v: f64) -> Rgb {
|
||||
c.modified(name, v)
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
c.with_lum(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+156
-40
@@ -16,6 +16,7 @@ mod wordart;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
@@ -96,6 +97,10 @@ pub(crate) struct Reader<'p> {
|
||||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||||
/// holding the text), text box part). See [`link_text_boxes`].
|
||||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||||
/// The theme's fill styles, which shapes' style references point to.
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<wordcraft_doc::fill::Fill>,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
@@ -159,6 +164,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
content_types: None,
|
||||
embedded: EmbeddedManifest::default(),
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
@@ -178,7 +185,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
log::warn!("docx: ignoring unreadable {what}: {e}");
|
||||
}
|
||||
};
|
||||
// Styles come before settings, but their theme colours already follow its colour mapping.
|
||||
// Styles come before settings, but their theme colours already follow its colour mapping, and
|
||||
// their `…Bidi` theme fonts its theme font language.
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
r.read_color_mapping(&p);
|
||||
}
|
||||
@@ -252,11 +260,23 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
toc_control_to_toc(&mut r.doc.body);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// A watermark in the box Word's Design › Watermark gives it for the page keeps no box of its
|
||||
// own: it follows the page, as when it was made.
|
||||
let (pw, ph) = (r.doc.last_section.page_w, r.doc.last_section.page_h);
|
||||
if let Some(wm) = r.doc.settings.watermark.as_mut()
|
||||
&& let Some([w, h]) = wm.size
|
||||
{
|
||||
let [dw, dh] = wordcraft_doc::Watermark { size: None, ..wm.clone() }.frame(pw, ph);
|
||||
if (w - dw).abs() < 0.06 && (h - dh).abs() < 0.06 {
|
||||
wm.size = None;
|
||||
}
|
||||
}
|
||||
|
||||
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
|
||||
r.read_core(&core);
|
||||
@@ -416,12 +436,21 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
||||
/// 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.
|
||||
/// those runs so they look the same here; the links stay. The style is found by its built-in
|
||||
/// name: localized Word and older files give it another id (`Hipervnculo`, `Hyperlnk`).
|
||||
fn unstyle_toc_links(doc: &mut Document) {
|
||||
let ids: HashSet<String> = doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == StyleKind::Character && s.name.eq_ignore_ascii_case("Hyperlink"))
|
||||
.map(|s| s.id.clone())
|
||||
.chain(["Hyperlink".to_string()])
|
||||
.collect();
|
||||
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") {
|
||||
if c.link.is_some() && c.style.as_deref().is_some_and(|s| ids.contains(s)) {
|
||||
c.style = None;
|
||||
}
|
||||
});
|
||||
@@ -429,47 +458,113 @@ fn unstyle_toc_links(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Word's Table of Contents content control belongs to the TOC: the writer puts it back around
|
||||
/// the TOC heading and its entries (`toc_span`), which References › Update Table rebuilds. It is
|
||||
/// handed to the TOC only when the writer would put it back around the same paragraphs; any other
|
||||
/// is kept as read, or saving would drop it (#2427).
|
||||
fn toc_control_to_toc(body: &mut Blocks) {
|
||||
let span = {
|
||||
let events = control_events(body);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
pairs.iter().find_map(|&(o, c, depth)| {
|
||||
let (ControlEvent::Open { block: s, off: Some(0), control }, ControlEvent::Close { block: e, off: Some(off) }) =
|
||||
(events.get(o)?, events.get(c)?)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let toc = matches!(&control.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
|
||||
let at_end = body.get(*e)?.as_para()?.len() == off.checked_add(OBJ.len_utf8())?;
|
||||
(depth == 0 && toc && at_end).then_some((*s, *e, *off))
|
||||
})
|
||||
};
|
||||
let Some((s, e, off)) = span else { return };
|
||||
let mut toc = body.clone();
|
||||
// The end first: both markers may be in the same paragraph.
|
||||
if let Some(Block::Para(p)) = toc.get_mut(e).map(Arc::make_mut) {
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = toc.get_mut(s).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
toc_field_to_heading(&mut toc);
|
||||
if crate::write::written_toc_span(&toc) == Some((s, e)) {
|
||||
*body = toc;
|
||||
return;
|
||||
}
|
||||
// TOCs the writer rebuilds in the same paragraphs once Update Table has turned them into the
|
||||
// engine's TOC (`range_toc_to_field`) are handed over as the range read, as without the control:
|
||||
// - Word's layout, the field's end alone in a paragraph after the entries (it goes with them);
|
||||
// - ours with a heading that isn't a `TOCHeading`, written empty and the field opening in the
|
||||
// first entry. Kept in the control, that paragraph wouldn't be empty and each save and update
|
||||
// would add a blank line above the entries (#2182).
|
||||
let mut engine = toc.clone();
|
||||
let mut last = e;
|
||||
// The entry before Word's paragraph holding nothing but the field's end, and that end's format.
|
||||
let end_only = e.checked_sub(1).filter(|&l| l > s).and_then(|l| {
|
||||
let p = engine.get(e)?.as_para()?;
|
||||
let only = p.section.is_none() && p.len() == OBJ.len_utf8() && matches!(p.object_at(0), Some(InlineObject::FieldEnd));
|
||||
only.then(|| (l, p.props_of_char(0).clone()))
|
||||
});
|
||||
if let Some((l, props)) = end_only {
|
||||
engine.remove(e);
|
||||
last = l;
|
||||
if let Some(Block::Para(p)) = engine.get_mut(l).map(Arc::make_mut) {
|
||||
let len = p.len();
|
||||
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
|
||||
}
|
||||
}
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
for i in 1..body.len() {
|
||||
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
continue;
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: String::new(),
|
||||
locked: *locked,
|
||||
dirty: *dirty,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
|
||||
/// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
|
||||
/// field already.
|
||||
fn field_to_heading(body: &mut Blocks, i: usize) {
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
return;
|
||||
}
|
||||
let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +812,7 @@ impl Reader<'_> {
|
||||
self.pc.theme_colors = colors.clone();
|
||||
self.doc.settings.theme_colors = colors;
|
||||
}
|
||||
self.theme_fills = shape_style::ThemeFills::read(el.child("a:fmtScheme"));
|
||||
if let Some(fs) = el.child("a:fontScheme") {
|
||||
let face =
|
||||
|n: &str| fs.child(n).and_then(|f| f.child("a:latin")).and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
@@ -729,15 +825,30 @@ impl Reader<'_> {
|
||||
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
|
||||
self.pc.major_cs = cs("a:majorFont");
|
||||
self.pc.minor_cs = cs("a:minorFont");
|
||||
// Per-script fonts (`a:font script="Arab"`); Office's themes list about 30.
|
||||
let scripts = |n: &str| -> Vec<(String, String)> {
|
||||
let Some(f) = fs.child(n) else { return Vec::new() };
|
||||
f.els()
|
||||
.filter(|e| e.name == "a:font")
|
||||
.filter_map(|e| Some((e.attr("script")?, e.attr("typeface")?)))
|
||||
.filter(|(s, t)| s.len() <= 8 && !t.is_empty() && t.len() < 256)
|
||||
.take(256)
|
||||
.map(|(s, t)| (s.to_string(), t.to_string()))
|
||||
.collect()
|
||||
};
|
||||
self.pc.major_scripts = scripts("a:majorFont");
|
||||
self.pc.minor_scripts = scripts("a:minorFont");
|
||||
}
|
||||
self.pc.major_font = self.doc.settings.major_font.clone();
|
||||
self.pc.minor_font = self.doc.settings.minor_font.clone();
|
||||
self.pc.theme = self.doc.settings.theme_colors.clone();
|
||||
}
|
||||
|
||||
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for.
|
||||
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. Also
|
||||
/// `w:themeFontLang/@w:bidi`, the language whose script picks the `…Bidi` theme fonts.
|
||||
fn read_color_mapping(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
self.pc.bidi_script = root.child("w:themeFontLang").and_then(|l| l.attr("w:bidi")).filter(|v| !v.trim().is_empty()).map(props::lang_script);
|
||||
let Some(m) = root.child("w:clrSchemeMapping") else { return };
|
||||
let names = [
|
||||
"dark1",
|
||||
@@ -798,6 +909,10 @@ impl Reader<'_> {
|
||||
}
|
||||
if let Some(p) = dd.child("w:pPrDefault").and_then(|d| d.child("w:pPr")) {
|
||||
sheet.default_para = self.pc.ppr(p).0;
|
||||
// Word makes no paragraph a text frame from the document defaults: the sizeless
|
||||
// top-left `w:framePr` Apple Pages writes there would put every paragraph, the
|
||||
// headers' and footers' included, at the page's corner (#2360).
|
||||
sheet.default_para.frame = None;
|
||||
}
|
||||
}
|
||||
if sheet.default_chr.size.is_none() {
|
||||
@@ -894,7 +1009,8 @@ impl Reader<'_> {
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
t.col_band_size = tp.child_val("w:tblStyleColBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
|
||||
|
||||
@@ -44,9 +44,15 @@ use crate::xml::El;
|
||||
pub struct PropCtx {
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots fall back on.
|
||||
pub major_cs: String,
|
||||
pub minor_cs: String,
|
||||
/// The theme's per-script fonts (`a:font script="Arab" typeface="…"`).
|
||||
pub major_scripts: Vec<(String, String)>,
|
||||
pub minor_scripts: Vec<(String, String)>,
|
||||
/// The script (ISO 15924) of `w:themeFontLang/@w:bidi`, which picks the font a `…Bidi` theme
|
||||
/// slot names. `None` = the settings name no bidi language.
|
||||
pub bidi_script: Option<&'static str>,
|
||||
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
|
||||
/// package has none, and then `w:themeColor` falls back to the fixed colour.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
@@ -64,6 +70,64 @@ pub struct PropCtx {
|
||||
/// a document (`resolve_list_levels`).
|
||||
pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
|
||||
|
||||
/// The font Word draws a `minorBidi`/`majorBidi` theme slot in when the settings name no bidi
|
||||
/// language, or the theme has no font for it.
|
||||
const BIDI_THEME_FALLBACK_FONT: &str = "Times New Roman";
|
||||
|
||||
/// The script a language tag is written in, as a theme's `a:font script` names it (ISO 15924, and
|
||||
/// Office's own `Uigh` for Uyghur and `Viet` for Vietnamese): an explicit script subtag
|
||||
/// (`ku-Arab`), else the language's usual script. Languages not listed are taken as Latin.
|
||||
pub(crate) fn lang_script(tag: &str) -> &'static str {
|
||||
const SCRIPTS: &[&str] = &[
|
||||
"Arab", "Armn", "Beng", "Cans", "Cher", "Cyrl", "Deva", "Ethi", "Geor", "Grek", "Gujr", "Guru", "Hang", "Hans", "Hant", "Hebr", "Jpan",
|
||||
"Khmr", "Knda", "Laoo", "Latn", "Mlym", "Mong", "Mymr", "Orya", "Sinh", "Syrc", "Taml", "Telu", "Tfng", "Thaa", "Thai", "Tibt", "Uigh",
|
||||
"Viet", "Yiii",
|
||||
];
|
||||
let mut parts = tag.split(['-', '_']).filter(|p| !p.is_empty());
|
||||
let lang = parts.next().unwrap_or_default().to_ascii_lowercase();
|
||||
let rest: Vec<&str> = parts.take(8).collect();
|
||||
if let Some(s) = rest.iter().find_map(|p| SCRIPTS.iter().find(|s| s.eq_ignore_ascii_case(p))) {
|
||||
return s;
|
||||
}
|
||||
match lang.as_str() {
|
||||
"ar" | "fa" | "ur" | "ps" | "sd" | "ckb" | "prs" => "Arab",
|
||||
"ug" => "Uigh",
|
||||
"vi" => "Viet",
|
||||
"he" | "yi" => "Hebr",
|
||||
"syr" => "Syrc",
|
||||
"dv" => "Thaa",
|
||||
"th" => "Thai",
|
||||
"hi" | "mr" | "ne" | "sa" | "kok" => "Deva",
|
||||
"bn" | "as" => "Beng",
|
||||
// Measured in Word: Punjabi takes the Arabic-script font in Pakistan, Gurmukhi elsewhere.
|
||||
"pa" if rest.iter().any(|p| p.eq_ignore_ascii_case("pk")) => "Arab",
|
||||
"pa" => "Guru",
|
||||
"gu" => "Gujr",
|
||||
"or" => "Orya",
|
||||
"ta" => "Taml",
|
||||
"te" => "Telu",
|
||||
"kn" => "Knda",
|
||||
"ml" => "Mlym",
|
||||
"si" => "Sinh",
|
||||
"lo" => "Laoo",
|
||||
"km" => "Khmr",
|
||||
"bo" => "Tibt",
|
||||
"my" => "Mymr",
|
||||
"am" | "ti" => "Ethi",
|
||||
"chr" => "Cher",
|
||||
"iu" => "Cans",
|
||||
"ii" => "Yiii",
|
||||
"ka" => "Geor",
|
||||
"hy" => "Armn",
|
||||
"el" => "Grek",
|
||||
"ja" => "Jpan",
|
||||
"ko" => "Hang",
|
||||
"zh" if rest.iter().any(|p| ["tw", "hk", "mo"].iter().any(|r| r.eq_ignore_ascii_case(p))) => "Hant",
|
||||
"zh" => "Hans",
|
||||
_ => "Latn",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -86,11 +150,25 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
fn theme_font(&self, t: &str) -> Option<String> {
|
||||
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
|
||||
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
|
||||
// names nothing, so the font beside it or the inherited one applies.
|
||||
let f = if t.ends_with("Bidi") { cs } else { latin };
|
||||
(!f.is_empty()).then(|| f.clone())
|
||||
let major = t.starts_with("major");
|
||||
let latin = if major { &self.major_font } else { &self.minor_font };
|
||||
if t.ends_with("Bidi") {
|
||||
return Some(self.bidi_theme_font(major));
|
||||
}
|
||||
(!latin.is_empty()).then(|| latin.clone())
|
||||
}
|
||||
|
||||
/// The font a `majorBidi`/`minorBidi` theme slot names. Measured in Word for Mac: the theme's
|
||||
/// font for the script of the settings' `w:themeFontLang/@w:bidi` (its `a:latin` for a Latin
|
||||
/// language), else the theme's `a:cs`, else Times New Roman; with no bidi language there,
|
||||
/// Times New Roman whatever the theme says. The run's own `w:lang` plays no part.
|
||||
fn bidi_theme_font(&self, major: bool) -> String {
|
||||
let (latin, cs, scripts) =
|
||||
if major { (&self.major_font, &self.major_cs, &self.major_scripts) } else { (&self.minor_font, &self.minor_cs, &self.minor_scripts) };
|
||||
let named = |f: &String| (!f.is_empty()).then(|| f.clone());
|
||||
let Some(script) = self.bidi_script else { return BIDI_THEME_FALLBACK_FONT.to_string() };
|
||||
let by_script = if script == "Latn" { named(latin) } else { scripts.iter().find(|(s, _)| s == script).and_then(|(_, f)| named(f)) };
|
||||
by_script.or_else(|| named(cs)).unwrap_or_else(|| BIDI_THEME_FALLBACK_FONT.to_string())
|
||||
}
|
||||
|
||||
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
|
||||
@@ -227,8 +305,9 @@ impl PropCtx {
|
||||
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
|
||||
}
|
||||
"w:lang" => {
|
||||
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang = k.attr("w:val").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_ea = k.attr("w:eastAsia").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
}
|
||||
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
|
||||
n if n.starts_with("w14:") => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
|
||||
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
use super::drawing_color::{color, fill_colour};
|
||||
use super::drawing_color::{color, color_alpha, fill as read_fill, fill_colour};
|
||||
use crate::units::{int, measure};
|
||||
use crate::xml::El;
|
||||
|
||||
@@ -108,29 +112,76 @@ pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
|
||||
/// A shape's fill and outline.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct ShapePaint {
|
||||
pub fill: Option<Rgb>,
|
||||
pub fill: Option<Fill>,
|
||||
pub stroke: Option<Rgb>,
|
||||
/// Points.
|
||||
pub stroke_width: f32,
|
||||
/// The outline's gradient (drawn in its main colour, `stroke`).
|
||||
pub outline_fill: Option<Fill>,
|
||||
}
|
||||
|
||||
/// The theme's fill styles (`a:fmtScheme`): `a:fillStyleLst` and `a:bgFillStyleLst`, which a
|
||||
/// shape's `a:fillRef` 1–999 and 1001+ point to.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ThemeFills {
|
||||
pub fill: Vec<El>,
|
||||
pub bg: Vec<El>,
|
||||
}
|
||||
|
||||
impl ThemeFills {
|
||||
pub fn read(fmt: Option<&El>) -> ThemeFills {
|
||||
let list = |n: &str| fmt.and_then(|f| f.child(n)).map(|l| l.els().take(MAX_THEME_FILLS).cloned().collect()).unwrap_or_default();
|
||||
ThemeFills { fill: list("a:fillStyleLst"), bg: list("a:bgFillStyleLst") }
|
||||
}
|
||||
/// Style `idx` (1-based; 1001+ the background styles; 1000 names none).
|
||||
fn get(&self, idx: i64) -> Option<&El> {
|
||||
let (list, i) = if idx > 1000 { (&self.bg, idx - 1001) } else { (&self.fill, idx - 1) };
|
||||
list.get(usize::try_from(i).ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most fill styles kept per theme list: every one a `a:fillRef` can reach (1–999 into
|
||||
/// `a:fillStyleLst`, 1001–1999 into `a:bgFillStyleLst`, ECMA-376 Part 1 §20.1.4.2.10), and no
|
||||
/// more, so a hostile theme with millions of entries keeps a bounded list.
|
||||
const MAX_THEME_FILLS: usize = 999;
|
||||
|
||||
/// The fill a `…spPr` element states itself: `Some(None)` for none (`a:noFill`, picture fills),
|
||||
/// `group` for `a:grpFill`, `None` when it leaves the fill to the shape's style.
|
||||
pub(crate) fn own_fill(sppr: Option<&El>, theme: &[Rgb], group: Option<&Fill>) -> Option<Option<Fill>> {
|
||||
let f = sppr?.els().find(|c| matches!(c.name.as_str(), "a:noFill" | "a:blipFill" | "a:grpFill" | "a:solidFill" | "a:gradFill" | "a:pattFill"))?;
|
||||
Some(match f.name.as_str() {
|
||||
"a:grpFill" => group.cloned(),
|
||||
_ => read_fill(f, theme, None),
|
||||
})
|
||||
}
|
||||
|
||||
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
|
||||
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an
|
||||
/// explicit colour wins, and a style reference with index 0 is none.
|
||||
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint {
|
||||
/// colours `theme`, fill styles `styles`) for what they leave out: an explicit `a:noFill` is no
|
||||
/// fill or outline, an explicit colour wins, `a:grpFill` is the enclosing group's fill `group`,
|
||||
/// and a style reference with index 0 is none.
|
||||
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb], styles: &ThemeFills, group: Option<&Fill>) -> ShapePaint {
|
||||
let style_ref = |name: &str| -> Option<(i64, &El)> {
|
||||
let r = style?.child(name)?;
|
||||
Some((r.attr("idx").and_then(int).unwrap_or(0), r))
|
||||
};
|
||||
let fill = match sppr {
|
||||
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None,
|
||||
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme),
|
||||
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)),
|
||||
};
|
||||
let fill = own_fill(sppr, theme, group).unwrap_or_else(|| {
|
||||
let (idx, r) = style_ref("a:fillRef").filter(|(idx, _)| *idx > 0)?;
|
||||
// The theme style in the reference's colour (`phClr`), else that colour: also for a style
|
||||
// the model can't hold (a picture fill), so the shape doesn't vanish.
|
||||
let ph = color_alpha(r, theme, None)?;
|
||||
let solid = Fill::Solid { color: ph.0, transparency: ph.1 };
|
||||
match styles.get(idx) {
|
||||
Some(st) if st.name == "a:noFill" => None,
|
||||
Some(st) => read_fill(st, theme, Some(ph)).or(Some(solid)),
|
||||
None => Some(solid),
|
||||
}
|
||||
});
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
|
||||
let outline_fill = ln.and_then(|l| l.child("a:gradFill")).and_then(|g| read_fill(g, theme, None)).filter(|f| matches!(f, Fill::Gradient(_)));
|
||||
let stroke = match ln {
|
||||
Some(l) if l.child("a:noFill").is_some() => None,
|
||||
_ if outline_fill.is_some() => outline_fill.as_ref().map(Fill::main_color),
|
||||
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
|
||||
_ => line_ref.and_then(|(_, r)| color(r, theme)),
|
||||
};
|
||||
@@ -142,7 +193,86 @@ pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb])
|
||||
// Word's default outline when the file names none (no outline: no width).
|
||||
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
|
||||
.clamp(0.0, 100.0);
|
||||
ShapePaint { fill, stroke, stroke_width }
|
||||
ShapePaint { fill, stroke, stroke_width, outline_fill }
|
||||
}
|
||||
|
||||
/// A VML shape's fill: `fillcolor` (white by default) and its `v:fill`: opacity, and a linear
|
||||
/// (`type="gradient"`) or circular (`gradientRadial`) gradient to `color2`, or through `colors`.
|
||||
/// A gradient's `focus` (−100%–100%) and `angle` are approximated: 0 and 100% run `color` to
|
||||
/// `color2`, −100% the other way, and a focus between mirrors the gradient there (`color2`
|
||||
/// there for a positive focus, `color` for a negative one). `None` when not filled.
|
||||
pub(crate) fn vml_fill(shape: &El, shapetype: Option<&El>) -> Option<Fill> {
|
||||
// What the shape leaves out comes from its shapetype (#1377).
|
||||
let own = |n: &str| shape.attr(n).or_else(|| shapetype.and_then(|t| t.attr(n)));
|
||||
let f = shape.child("v:fill").or_else(|| shapetype.and_then(|t| t.child("v:fill")));
|
||||
let attr = |n: &str| f.and_then(|f| f.attr(n));
|
||||
let off = |v: Option<&str>| {
|
||||
v.is_some_and(|v| {
|
||||
let t = v.trim();
|
||||
t.eq_ignore_ascii_case("f") || t.eq_ignore_ascii_case("false") || t == "0"
|
||||
})
|
||||
};
|
||||
if off(own("filled")) || off(attr("on")) {
|
||||
return None;
|
||||
}
|
||||
let c1 = attr("color").or(own("fillcolor")).map_or(Some(Rgb::WHITE), vml_color)?;
|
||||
let t1 = 100.0 - attr("opacity").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
|
||||
let radial = match attr("type") {
|
||||
Some("gradient") => false,
|
||||
Some("gradientRadial") => true,
|
||||
_ => return Fill::Solid { color: c1, transparency: t1 }.sanitized(),
|
||||
};
|
||||
let c2 = attr("color2").and_then(vml_color).unwrap_or(Rgb::WHITE);
|
||||
let t2 = 100.0 - attr("o:opacity2").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
|
||||
let stop = |pos: f32, (color, transparency): (Rgb, f32)| GradStop { pos, color, transparency };
|
||||
let (a, b) = ((c1, t1), (c2, t2));
|
||||
let focus = attr("focus").and_then(vml_fraction).unwrap_or(0.0).clamp(-1.0, 1.0) * 100.0;
|
||||
let colors: Vec<GradStop> = attr("colors")
|
||||
.into_iter()
|
||||
.flat_map(|v| v.split(';'))
|
||||
.take(MAX_STOPS)
|
||||
.filter_map(|s| {
|
||||
let (pos, c) = s.trim().split_once(' ')?;
|
||||
Some(stop(vml_fraction(pos)? * 100.0, (vml_color(c)?, t1)))
|
||||
})
|
||||
.collect();
|
||||
let stops = if colors.len() >= 2 {
|
||||
colors
|
||||
} else if focus <= -100.0 {
|
||||
vec![stop(0.0, b), stop(100.0, a)]
|
||||
} else if focus >= 100.0 || focus == 0.0 {
|
||||
vec![stop(0.0, a), stop(100.0, b)]
|
||||
} else if focus > 0.0 {
|
||||
vec![stop(0.0, a), stop(focus, b), stop(100.0, a)]
|
||||
} else {
|
||||
vec![stop(0.0, b), stop(-focus, a), stop(100.0, b)]
|
||||
};
|
||||
let kind = if radial {
|
||||
// `focusposition` "x,y" (fractions of the shape) is the centre.
|
||||
let fp = attr("focusposition").unwrap_or("0.5,0.5");
|
||||
let (x, y) = fp.split_once(',').unwrap_or((fp, fp));
|
||||
let (x, y) = (vml_fraction(x).unwrap_or(0.5) * 100.0, vml_fraction(y).unwrap_or(0.5) * 100.0);
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [x, y, 100.0 - x, 100.0 - y] }
|
||||
} else {
|
||||
// VML's angle is counter-clockwise from a top-to-bottom gradient.
|
||||
let ang = attr("angle").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
GradientKind::Linear { angle: 90.0 - ang, scaled: false }
|
||||
};
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None })).sanitized()
|
||||
}
|
||||
|
||||
/// A VML fraction (`opacity`, `focus`, a `colors` position): `0.5`, `50%`, or 65536ths
|
||||
/// (`32768f`). Not clamped: a focus runs from -1 to 1.
|
||||
pub(crate) fn vml_fraction(v: &str) -> Option<f32> {
|
||||
let v = v.trim();
|
||||
let n = if let Some(f) = v.strip_suffix('f') {
|
||||
f.trim().parse::<f32>().ok()? / 65_536.0
|
||||
} else if let Some(p) = v.strip_suffix('%') {
|
||||
p.trim().parse::<f32>().ok()? / 100.0
|
||||
} else {
|
||||
v.parse::<f32>().ok()?
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
/// Explicit DrawingML dash stops override the solid default.
|
||||
@@ -222,7 +352,7 @@ mod tests {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!(p.stroke, Some(THEME_COLORS[7]));
|
||||
assert_eq!(p.stroke_width, 0.5);
|
||||
assert_eq!(p.fill, None);
|
||||
@@ -239,32 +369,94 @@ mod tests {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
|
||||
// Preset and scheme colours as the box's own fill and outline.
|
||||
let sp = el(
|
||||
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), None, &THEME_COLORS);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5));
|
||||
let p = shape_paint(Some(&sp), None, &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(Fill::solid(THEME_COLORS[1])), Some(Rgb(0, 0, 0)), 0.5));
|
||||
// A style fill with index 0 is none; an outline set to no fill is none.
|
||||
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_picture_fill_falls_back_on_the_reference_colour() {
|
||||
let fmt = el(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst><a:noFill/><a:blipFill><a:blip/></a:blipFill></a:fillStyleLst></a:fmtScheme>"#,
|
||||
);
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
let st = |idx: u8| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
assert_eq!(shape_paint(None, Some(&st(2)), &THEME_COLORS, &styles, None).fill, Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
|
||||
// An explicit theme `a:noFill` stays none.
|
||||
assert_eq!(shape_paint(None, Some(&st(1)), &THEME_COLORS, &styles, None).fill, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_reachable_theme_fill_style_is_kept() {
|
||||
// 20 fill styles and 20 background styles: the 17th of each a gradient / none, the rest
|
||||
// solid, so a reference past Word's usual three still finds its style.
|
||||
let entries = |odd: &str| {
|
||||
(1..=20)
|
||||
.map(|i| if i == 17 { odd.to_string() } else { r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#.to_string() })
|
||||
.collect::<String>()
|
||||
};
|
||||
let grad = r#"<a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
|
||||
let fmt = el(&format!(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{}</a:fillStyleLst><a:bgFillStyleLst>{}</a:bgFillStyleLst></a:fmtScheme>"#,
|
||||
entries(grad),
|
||||
entries("<a:noFill/>")
|
||||
));
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
let st = |idx: u32| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
let fill = |idx| shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill;
|
||||
assert!(matches!(fill(17), Some(Fill::Gradient(_))), "{:?}", fill(17));
|
||||
assert_eq!(fill(1017), None);
|
||||
assert_eq!(fill(16), Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_with_absurdly_many_fill_styles_stays_bounded() {
|
||||
let many = "<a:noFill/>".repeat(100_000);
|
||||
let fmt = el(&format!(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{many}</a:fillStyleLst><a:bgFillStyleLst>{many}</a:bgFillStyleLst></a:fmtScheme>"#
|
||||
));
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
assert_eq!((styles.fill.len(), styles.bg.len()), (MAX_THEME_FILLS, MAX_THEME_FILLS));
|
||||
let st = |idx: &str| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
// The last reachable style of each list is kept; past them (and 1000) the reference colour.
|
||||
for (idx, want) in [
|
||||
("999", None),
|
||||
("1999", None),
|
||||
("1000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
("2000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
("99999999999", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
] {
|
||||
assert_eq!(shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill, want, "idx {idx}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_style_references_do_not_panic() {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(None, Some(&st), &[]);
|
||||
let p = shape_paint(None, Some(&st), &[], &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke), (None, None));
|
||||
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
|
||||
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5);
|
||||
assert_eq!(shape_paint(None, Some(&st), &[], &ThemeFills::default(), None).stroke_width, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,6 +489,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_gradients_focus_and_colours() {
|
||||
let v = |attrs: &str, fill: &str| {
|
||||
let ns = r#"xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
vml_fill(&crate::xml::parse(format!(r#"<v:rect {ns} {attrs}>{fill}</v:rect>"#).as_bytes()).unwrap(), None)
|
||||
};
|
||||
let stops = |f: Option<Fill>| match f {
|
||||
Some(Fill::Gradient(g)) => g.stops.iter().map(|s| (s.pos, s.color)).collect::<Vec<_>>(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (red, blue) = (Rgb(255, 0, 0), Rgb(0, 0, 255));
|
||||
// A focus of 50% mirrors the gradient in the middle; 100% runs as 0%, −100% reverses it.
|
||||
assert_eq!(
|
||||
stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="50%"/>"##)),
|
||||
[(0.0, red), (50.0, blue), (100.0, red)]
|
||||
);
|
||||
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="100%"/>"##)), [(0.0, red), (100.0, blue)]);
|
||||
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="-100%"/>"##)), [(0.0, blue), (100.0, red)]);
|
||||
assert_eq!(v("", r##"<v:fill opacity="-2"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 100.0 }));
|
||||
// `colors` lists the stops (fractions or 65536ths).
|
||||
assert_eq!(stops(v("", r##"<v:fill type="gradient" colors="0 red;32768f blue;1 red"/>"##)), [(0.0, red), (50.0, blue), (100.0, red)]);
|
||||
let Some(Fill::Gradient(g)) = v(r##"fillcolor="red""##, r##"<v:fill type="gradientRadial" color2="blue" focusposition=".25,.75"/>"##) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [25.0, 75.0, 75.0, 25.0] });
|
||||
assert_eq!(v(r##"filled="f""##, ""), None);
|
||||
assert_eq!(v("", r##"<v:fill opacity="32768f"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 50.0 }));
|
||||
// Hostile values don't panic.
|
||||
assert!(v("", r##"<v:fill type="gradient" colors="x;;1 #zz;NaN red" focus="NaN" angle="inf" opacity="1e99f"/>"##).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_colours() {
|
||||
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
|
||||
+180
-56
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap};
|
||||
use wordcraft_doc::control::{ContentControl, ControlWrap};
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, PTabAlign, PTabBase, ShapeKind, Wrap, WrapText, cap_alt};
|
||||
@@ -13,7 +13,7 @@ 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, vml_preset_kind};
|
||||
use super::shape_style::{own_fill, preset_kind, shape_paint, vml_color, vml_fill, vml_fraction, vml_preset_kind};
|
||||
use crate::package::{ContentTypes, Rels, rt};
|
||||
use crate::units::{int, measure, u32_of};
|
||||
use crate::xml::{El, Node};
|
||||
@@ -101,6 +101,69 @@ fn is_range_instr(instr: &str) -> bool {
|
||||
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
|
||||
/// `#bookmark`. None for any other field, or one with no target.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some((a, quoted)) = args.next() {
|
||||
if *quoted || !a.starts_with('\\') {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
|
||||
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l") {
|
||||
anchor = value.map(|(v, _)| v.as_str());
|
||||
}
|
||||
}
|
||||
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
|
||||
match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The last paragraph of a block list: its last block, or inside a final table the last
|
||||
/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]).
|
||||
fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> {
|
||||
@@ -265,10 +328,9 @@ impl Reader<'_> {
|
||||
ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r));
|
||||
let mut inner = Blocks::new();
|
||||
self.read_blocks(sc, content, rels, &mut inner, depth + 1);
|
||||
// A table of contents' control belongs to the TOC: the writer puts it back around the
|
||||
// TOC field and its entries, which References › Update Table rebuilds.
|
||||
let toc = matches!(&ctl.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
|
||||
if inner.is_empty() || toc {
|
||||
// A table of contents' control is handed to the TOC later, when the writer puts it back
|
||||
// (`toc_control_to_toc`).
|
||||
if inner.is_empty() {
|
||||
out.extend(inner);
|
||||
return;
|
||||
}
|
||||
@@ -702,8 +764,12 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
if let Some(o) = self.read_signature_line(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
"w:object" => {
|
||||
@@ -889,6 +955,19 @@ impl Reader<'_> {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
// A `HYPERLINK` field is a link, as a `w:hyperlink` is: its result stays ordinary content
|
||||
// carrying the link, so a tab in it reaches its tab stop and the `PAGEREF` in a table of
|
||||
// contents entry (Word for the web writes them this way) stays a field of its own (#2174).
|
||||
if !nested_in_instr && let Some(target) = hyperlink_target(&f.instr) {
|
||||
for (it, mut p) in f.buf {
|
||||
p.link.get_or_insert_with(|| target.clone());
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
|
||||
// picture) can't be a text result: keep the field as a range around it so the picture
|
||||
// stays in the document and is saved again. The linked source is never read.
|
||||
@@ -1048,14 +1127,19 @@ impl Reader<'_> {
|
||||
float.effect = float.effect.map(|e| e.max(0.0));
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
// A textbox may contain a picture; its descendant blip is not the outer payload.
|
||||
let direct_shape = gd.child("wps:wsp").filter(|shape| {
|
||||
// Picture fills still use the image fallback; the shape model has no image fill.
|
||||
shape.child("wps:spPr").and_then(|props| props.child("a:blipFill")).is_none()
|
||||
});
|
||||
let mut obj = if let Some(c) = gd.child("wpc:wpc") {
|
||||
self.read_canvas(sc, c, rels, w, h, float)
|
||||
} else if let Some(g) = gd.child("wpg:wgp") {
|
||||
self.read_group(sc, g, rels, w, h, float)?
|
||||
} else if gd.find("a:blip").is_some() {
|
||||
} else if direct_shape.is_none() && gd.find("a:blip").is_some() {
|
||||
self.read_pic(gd, rels, w, h, alt.clone(), float)?
|
||||
} else {
|
||||
let wsp = gd.find("wps:wsp")?;
|
||||
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
@@ -1246,7 +1330,10 @@ impl Reader<'_> {
|
||||
};
|
||||
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
let outer = self.group_fill.take();
|
||||
self.group_fill = own_fill(g.child("wpg:grpSpPr"), &self.doc.settings.theme_colors, None).flatten();
|
||||
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
self.group_fill = outer;
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1266,7 +1353,7 @@ impl Reader<'_> {
|
||||
};
|
||||
let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln"));
|
||||
let style = wordcraft_doc::para::CanvasStyle {
|
||||
fill: solid(c.child("wpc:bg")),
|
||||
fill: own_fill(c.child("wpc:bg"), &self.doc.settings.theme_colors, None).flatten(),
|
||||
stroke: solid(ln),
|
||||
stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
};
|
||||
@@ -1332,7 +1419,12 @@ impl Reader<'_> {
|
||||
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
// Its own fill, for its members' `a:grpFill` (#1500).
|
||||
// One without a fill of its own passes its group's on.
|
||||
let own = own_fill(sppr, &self.doc.settings.theme_colors, self.group_fill.as_ref()).unwrap_or_else(|| self.group_fill.clone());
|
||||
let outer = std::mem::replace(&mut self.group_fill, own);
|
||||
self.group_members(sc, e, rels, nt, depth + 1, out);
|
||||
self.group_fill = outer;
|
||||
continue;
|
||||
}
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
@@ -1380,7 +1472,7 @@ impl Reader<'_> {
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
// Explicit paint, else the theme's through the style references (#611).
|
||||
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors);
|
||||
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors, &self.theme_fills, self.group_fill.as_ref());
|
||||
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
|
||||
let fill = if kind.is_open() { None } else { paint.fill };
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
@@ -1441,7 +1533,7 @@ impl Reader<'_> {
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), ..Default::default() };
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
|
||||
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
|
||||
// rounded rectangles keep the default.
|
||||
if prst == "roundRect" {
|
||||
@@ -1489,7 +1581,10 @@ impl Reader<'_> {
|
||||
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
let mut inner = StoryCtx { story_depth: sc.story_depth + 1, ..Default::default() };
|
||||
let mut blocks = Blocks::new();
|
||||
// Shapes in the text aren't members of a group the box may be in.
|
||||
let group = self.group_fill.take();
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
|
||||
self.group_fill = group;
|
||||
self.flush_pending(&mut inner, &mut blocks);
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
}
|
||||
@@ -1624,10 +1719,15 @@ impl Reader<'_> {
|
||||
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
|
||||
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
|
||||
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
|
||||
let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
|
||||
let on = |name: &str| {
|
||||
attr(name).is_none_or(|v| {
|
||||
let t = v.trim();
|
||||
!t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0"
|
||||
})
|
||||
};
|
||||
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = on("filled").then(|| attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
|
||||
let fill = vml_fill(shape, shapetype);
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
@@ -1702,14 +1802,17 @@ impl Reader<'_> {
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text");
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text"));
|
||||
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
|
||||
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
|
||||
if let Some(a) = vml_style(style, "v-text-anchor") {
|
||||
part.body.anchor = match a {
|
||||
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center,
|
||||
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom,
|
||||
_ => wordcraft_doc::props::VAlign::Top,
|
||||
if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) {
|
||||
let al = a.trim().to_ascii_lowercase();
|
||||
part.body.anchor = if al.starts_with("middle") || al == "center" {
|
||||
wordcraft_doc::props::VAlign::Center
|
||||
} else if al.starts_with("bottom") {
|
||||
wordcraft_doc::props::VAlign::Bottom
|
||||
} else {
|
||||
wordcraft_doc::props::VAlign::Top
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1737,7 +1840,7 @@ impl Reader<'_> {
|
||||
&& let Some(path) = shape.attr("path")
|
||||
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
|
||||
{
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
|
||||
let stroke = stroke.filter(|_| !vp.no_stroke);
|
||||
if stroke.is_none() && fill.is_none() {
|
||||
@@ -1819,6 +1922,23 @@ impl Reader<'_> {
|
||||
Some(o)
|
||||
}
|
||||
|
||||
/// A Word signature line (Insert › Signature Line): a `w:pict` whose one VML picture carries
|
||||
/// an `o:signatureline` (the suggested signer, title, e-mail and instructions). Shown as its
|
||||
/// picture, and kept whole like an OLE object so saving writes the signature line back,
|
||||
/// still signable in Word, not a plain picture. `None` for any other `w:pict`.
|
||||
fn read_signature_line(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let mut drawings = pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype");
|
||||
let shape = drawings.next().filter(|s| s.name == "v:shape")?;
|
||||
if drawings.next().is_some() || shape.child("o:signatureline").is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut o = self.read_vml_shape(sc, pict, shape, rels, None)?;
|
||||
if let InlineObject::Image { ole, .. } = &mut o {
|
||||
*ole = self.keep_embedded(pict, rels, &[]);
|
||||
}
|
||||
Some(o)
|
||||
}
|
||||
|
||||
// ---- tables ----
|
||||
|
||||
fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> {
|
||||
@@ -1940,7 +2060,10 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) {
|
||||
}
|
||||
|
||||
fn anchor_float(c: &El) -> Float {
|
||||
let mut f = Float { wrap: Wrap::Square, ..Float::default() };
|
||||
// `relativeHeight`: the stacking order Word draws floating objects in (higher on top), which
|
||||
// need not be their order in the file.
|
||||
let z = c.attr("relativeHeight").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
|
||||
let mut f = Float { wrap: Wrap::Square, z, ..Float::default() };
|
||||
let behind = on_off_attr(c, "behindDoc");
|
||||
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
|
||||
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
|
||||
@@ -2126,37 +2249,43 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
|
||||
let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0);
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let behind = get("z-index").and_then(int).is_some_and(|z| z < 0);
|
||||
let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") {
|
||||
"square" => Wrap::Square,
|
||||
"tight" => Wrap::Tight,
|
||||
"through" => Wrap::Through,
|
||||
"topAndBottom" => Wrap::TopAndBottom,
|
||||
let wrap_el = shape.child("w10:wrap");
|
||||
let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none");
|
||||
let wrap = match wrap_type {
|
||||
t if t.eq_ignore_ascii_case("square") => Wrap::Square,
|
||||
t if t.eq_ignore_ascii_case("tight") => Wrap::Tight,
|
||||
t if t.eq_ignore_ascii_case("through") => Wrap::Through,
|
||||
t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom,
|
||||
_ if behind => Wrap::BehindText,
|
||||
_ => Wrap::InFrontOfText,
|
||||
};
|
||||
let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
"char" => Anchor::Character,
|
||||
"left-margin-area" => Anchor::LeftMargin,
|
||||
"right-margin-area" => Anchor::RightMargin,
|
||||
"inner-margin-area" => Anchor::InsideMargin,
|
||||
"outer-margin-area" => Anchor::OutsideMargin,
|
||||
let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default();
|
||||
let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text");
|
||||
let h_rel = match h_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("char") => Anchor::Character,
|
||||
s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin,
|
||||
s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
"line" => Anchor::Line,
|
||||
"top-margin-area" => Anchor::TopMargin,
|
||||
"bottom-margin-area" => Anchor::BottomMargin,
|
||||
"inner-margin-area" => Anchor::InsideMargin,
|
||||
"outer-margin-area" => Anchor::OutsideMargin,
|
||||
let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text");
|
||||
let v_rel = match v_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("line") => Anchor::Line,
|
||||
s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin,
|
||||
s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
let mut f = Float {
|
||||
wrap,
|
||||
wrap_text,
|
||||
h_rel,
|
||||
v_rel,
|
||||
x: (off("left") + off("margin-left")).clamp(-max, max),
|
||||
@@ -2207,21 +2336,16 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(default.font);
|
||||
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
|
||||
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0 })
|
||||
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
// The box the text is stretched to (`fitshape`).
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
|
||||
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
|
||||
}
|
||||
|
||||
/// A VML fraction (`opacity`): a number, a percentage, or 65536ths with an `f` suffix; 0..=1.
|
||||
fn vml_fraction(v: &str) -> Option<f32> {
|
||||
let v = v.trim();
|
||||
let f = if let Some(n) = v.strip_suffix('f') {
|
||||
n.trim().parse::<f32>().ok()? / 65_536.0
|
||||
} else if let Some(n) = v.strip_suffix('%') {
|
||||
n.trim().parse::<f32>().ok()? / 100.0
|
||||
} else {
|
||||
v.parse::<f32>().ok()?
|
||||
};
|
||||
f.is_finite().then(|| f.clamp(0.0, 1.0))
|
||||
/// A VML `opacity`, 0..=1.
|
||||
fn vml_opacity(v: &str) -> Option<f32> {
|
||||
vml_fraction(v).map(|f| f.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
|
||||
|
||||
@@ -13,13 +13,39 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
if t.is_empty() || t.len() > 40 {
|
||||
return None;
|
||||
}
|
||||
let split = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(t.len());
|
||||
let bytes = t.as_bytes();
|
||||
let mut split = t.len();
|
||||
let mut in_exp = false;
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b.is_ascii_digit() || b == b'.' {
|
||||
in_exp = false;
|
||||
continue;
|
||||
}
|
||||
if (b == b'-' || b == b'+') && (i == 0 || in_exp) {
|
||||
in_exp = false;
|
||||
continue;
|
||||
}
|
||||
if b == b'e' || b == b'E' {
|
||||
let rest = &bytes[i + 1..];
|
||||
let after_sign = match rest.first() {
|
||||
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
|
||||
_ => rest,
|
||||
};
|
||||
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
|
||||
in_exp = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
split = i;
|
||||
break;
|
||||
}
|
||||
let (num, unit) = t.split_at(split);
|
||||
let v: f64 = num.parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let pt = match unit.trim() {
|
||||
let unit_lower = unit.trim().to_ascii_lowercase();
|
||||
let pt = match unit_lower.as_str() {
|
||||
"" => v / per_pt as f64,
|
||||
"pt" => v,
|
||||
"in" => v * 72.0,
|
||||
@@ -119,6 +145,15 @@ mod tests {
|
||||
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
|
||||
assert_eq!(line_multiple("junk"), None);
|
||||
assert_eq!(line_multiple(""), None);
|
||||
assert_eq!(measure("12700emu", 20.0), Some(1.0));
|
||||
assert_eq!(measure("25400EMU", 20.0), Some(2.0));
|
||||
assert_eq!(measure("1IN", 20.0), Some(72.0));
|
||||
assert_eq!(measure("12PT", 2.0), Some(12.0));
|
||||
assert_eq!(measure("2.54CM", 20.0), Some(72.0));
|
||||
assert_eq!(measure("25.4MM", 20.0), Some(72.0));
|
||||
assert_eq!(measure("10PX", 20.0), Some(7.5));
|
||||
assert_eq!(measure("1e2pt", 20.0), Some(100.0));
|
||||
assert_eq!(measure("1e+2pt", 20.0), Some(100.0));
|
||||
assert_eq!(int("12.6"), Some(13));
|
||||
assert_eq!(u32_of("-5"), Some(0));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod story;
|
||||
pub(crate) use story::written_toc_span;
|
||||
mod watermark;
|
||||
mod wordart;
|
||||
|
||||
@@ -795,10 +796,13 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(t) = &st.table {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() || t.col_band_size.is_some() {
|
||||
w.open("w:tblPr", &[]);
|
||||
if let Some(n) = t.band_size {
|
||||
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
|
||||
w.val("w:tblStyleRowBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(n) = t.col_band_size {
|
||||
w.val("w:tblStyleColBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
|
||||
@@ -106,11 +106,18 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
}
|
||||
toggle(w, "w:rtl", c.rtl);
|
||||
toggle(w, "w:cs", c.cs);
|
||||
match (&c.lang, &c.lang_bidi) {
|
||||
(Some(l), Some(b)) => w.empty("w:lang", &[("w:val", l), ("w:bidi", b)]),
|
||||
(Some(l), None) => w.val("w:lang", l),
|
||||
(None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]),
|
||||
(None, None) => {}
|
||||
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
|
||||
if let Some(l) = &c.lang {
|
||||
lang_attrs.push(("w:val", l));
|
||||
}
|
||||
if let Some(ea) = &c.lang_ea {
|
||||
lang_attrs.push(("w:eastAsia", ea));
|
||||
}
|
||||
if let Some(b) = &c.lang_bidi {
|
||||
lang_attrs.push(("w:bidi", b));
|
||||
}
|
||||
if !lang_attrs.is_empty() {
|
||||
w.empty("w:lang", &lang_attrs);
|
||||
}
|
||||
// After w:lang (CT_RPr's sequence).
|
||||
if let Some(l) = &c.east_asian_layout {
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! elements around runs (inline) or around whole paragraphs and tables (block level). [`plan`]
|
||||
//! decides, per block list, which controls are written where so that the XML always nests.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Blocks;
|
||||
use wordcraft_doc::control::{ContentControl, ControlEvent, ControlKind, control_events, pair_events};
|
||||
use wordcraft_doc::para::OBJ;
|
||||
@@ -20,8 +18,11 @@ pub struct Plan<'a> {
|
||||
pub opens: Vec<Vec<&'a ContentControl>>,
|
||||
/// Controls closing after block `i`.
|
||||
pub closes: Vec<u32>,
|
||||
/// Paragraph markers written inline, as (block, byte offset).
|
||||
pub inline: HashSet<(usize, usize)>,
|
||||
/// The paragraph markers written inline, by block: the byte offsets in block `i`, in order.
|
||||
///
|
||||
/// Grouped by block rather than one set of (block, offset) pairs, so that asking for a
|
||||
/// paragraph's markers is a lookup and not a search of every marker in the document (#13).
|
||||
pub inline: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Plan<'_> {
|
||||
@@ -38,9 +39,7 @@ impl Plan<'_> {
|
||||
}
|
||||
/// The byte offsets of paragraph `i`'s markers that are written inline.
|
||||
pub fn inline_in(&self, i: usize) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.inline.iter().filter(|(b, _)| *b == i).map(|(_, o)| *o).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
self.inline.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +53,7 @@ enum Mode {
|
||||
/// Markers without a partner are not written.
|
||||
pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
let n = bl.len();
|
||||
let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: HashSet::new() };
|
||||
let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: vec![Vec::new(); n] };
|
||||
let events = control_events(bl);
|
||||
if events.is_empty() {
|
||||
return out;
|
||||
@@ -115,17 +114,37 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
}
|
||||
// Block-level controls nest by block: one that opens in the block another (not its
|
||||
// ancestor) closes after starts with the next block instead.
|
||||
//
|
||||
// Such a clash is with a control that closes before this one opens and ends in the block
|
||||
// this one starts in. The block-level controls are grouped by the block they end in, each
|
||||
// group keeping the two earliest closes in it: the earliest close that is not this
|
||||
// control's own is the one the test is about (#13).
|
||||
let mut first_close: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
let mut second_close: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
let mut first_owner: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
for (q, (_, qc)) in ranges.iter().enumerate() {
|
||||
if mode.get(q) != Some(&Mode::Block) {
|
||||
continue;
|
||||
}
|
||||
let Some(&(_, qe)) = span.get(q) else { continue };
|
||||
let e = qe.min(n);
|
||||
if *qc < first_close[e] {
|
||||
second_close[e] = first_close[e];
|
||||
first_close[e] = *qc;
|
||||
first_owner[e] = q;
|
||||
} else if *qc < second_close[e] {
|
||||
second_close[e] = *qc;
|
||||
}
|
||||
}
|
||||
for k in 0..ranges.len() {
|
||||
if mode.get(k) != Some(&Mode::Block) {
|
||||
continue;
|
||||
}
|
||||
let Some(&(o, _)) = ranges.get(k) else { continue };
|
||||
let Some(&(sb, eb)) = span.get(k) else { continue };
|
||||
let clash = ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(q, (_, qc))| q != k && *qc < o && mode.get(q) == Some(&Mode::Block) && span.get(q).is_some_and(|s| s.1 == sb));
|
||||
if clash {
|
||||
let e = sb.min(n);
|
||||
let close = if first_owner[e] == k { second_close[e] } else { first_close[e] };
|
||||
if close < o {
|
||||
let to = (sb + 1).min(eb);
|
||||
if let Some(s) = span.get_mut(k) {
|
||||
s.0 = to;
|
||||
@@ -146,13 +165,19 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
}
|
||||
_ => {
|
||||
for e in [events.get(*o), events.get(*c)].into_iter().flatten() {
|
||||
if let Some(off) = e.off() {
|
||||
out.inline.insert((e.block(), off));
|
||||
if let Some(off) = e.off()
|
||||
&& let Some(v) = out.inline.get_mut(e.block())
|
||||
{
|
||||
v.push(off);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in out.inline.iter_mut() {
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
|
||||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::fill::{Fill, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor};
|
||||
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
|
||||
@@ -35,8 +36,7 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let plan = super::sdt::plan(bl);
|
||||
// A TOC inside a content control already has one (read from a file).
|
||||
let toc = if top { toc_span(bl).filter(|(start, _)| !plan.covers(*start)) } else { None };
|
||||
let toc = if top { written_toc_span(bl) } else { None };
|
||||
// Whether the heading's TOC field was written and still needs its end.
|
||||
let mut toc_field_open = false;
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -720,14 +720,14 @@ impl Writer<'_> {
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
|
||||
w.open("wpc:wpc", &[]);
|
||||
w.open("wpc:bg", &[]);
|
||||
solid_fill(w, c.fill);
|
||||
write_fill(w, c.fill.as_ref());
|
||||
w.close("wpc:bg");
|
||||
w.open("wpc:whole", &[]);
|
||||
match c.stroke {
|
||||
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
|
||||
None => w.open("a:ln", &[]),
|
||||
}
|
||||
solid_fill(w, c.stroke);
|
||||
write_fill(w, c.stroke.map(Fill::solid).as_ref());
|
||||
w.close("a:ln");
|
||||
w.close("wpc:whole");
|
||||
"wpc:wpc"
|
||||
@@ -1074,28 +1074,27 @@ impl Writer<'_> {
|
||||
}
|
||||
let alpha = geom.map_or(1.0, |f| f.alpha);
|
||||
let ink = geom.is_some_and(|f| f.is_ink());
|
||||
match fill.filter(|_| !ink && !kind.is_open()) {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
write_fill(w, fill.as_ref().filter(|_| !ink && !kind.is_open()));
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
let width = emu(stroke_width.clamp(0.0, 100.0));
|
||||
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
|
||||
w.open("a:ln", ln);
|
||||
w.open("a:solidFill", &[]);
|
||||
if alpha < 1.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
|
||||
w.close("a:srgbClr");
|
||||
// An outline gradient read from a file, while the outline keeps its colour (#1500).
|
||||
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
|
||||
if grad.is_some() {
|
||||
write_fill(w, grad);
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.open("a:solidFill", &[]);
|
||||
if alpha < 1.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
|
||||
w.close("a:srgbClr");
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
line_dash(w, &extra.dash);
|
||||
if ink {
|
||||
w.empty("a:round", &[]);
|
||||
@@ -1184,7 +1183,9 @@ impl Writer<'_> {
|
||||
} else {
|
||||
let d = emu(float.dist.clamp(0.0, 1584.0));
|
||||
let (dt, db) = (emu(float.dist_top.clamp(0.0, 1584.0)), emu(float.dist_bottom.clamp(0.0, 1584.0)));
|
||||
self.z += 1;
|
||||
// The stacking order read from the file is kept; objects without one stack in
|
||||
// document order above it.
|
||||
self.z = if float.z > 0 { float.z } else { self.z.saturating_add(1) };
|
||||
let behind = if float.wrap == Wrap::BehindText { "1" } else { "0" };
|
||||
w.open(
|
||||
"wp:anchor",
|
||||
@@ -1688,6 +1689,12 @@ fn is_toc(instr: &str) -> bool {
|
||||
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// The body TOC the writer wraps in Word's Table of Contents control (`toc_span`): not one whose
|
||||
/// heading is inside a content control already (read from a file).
|
||||
pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
toc_span(bl).filter(|(start, _)| !super::sdt::plan(bl).covers(*start))
|
||||
}
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
@@ -1734,15 +1741,68 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
/// A DrawingML `a:solidFill` in `c`, or `a:noFill`.
|
||||
fn solid_fill(w: &mut W, c: Option<wordcraft_doc::props::Rgb>) {
|
||||
match c {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
/// A shape's fill: `a:solidFill` (with `a:alpha` when transparent), `a:gradFill` with everything
|
||||
/// read from the file, or `a:noFill`.
|
||||
fn write_fill(w: &mut W, f: Option<&Fill>) {
|
||||
let pct = |v: f32| n((v * 1000.0).round() as i64);
|
||||
let color = |w: &mut W, c: Rgb, transparency: f32| {
|
||||
if transparency > 0.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &pct(100.0 - transparency))]);
|
||||
w.close("a:srgbClr");
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
}
|
||||
};
|
||||
let rect = |r: [f32; 4]| [("l", pct(r[0])), ("t", pct(r[1])), ("r", pct(r[2])), ("b", pct(r[3]))];
|
||||
match f.and_then(Fill::sanitized) {
|
||||
None => w.empty("a:noFill", &[]),
|
||||
Some(Fill::Solid { color: c, transparency }) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
color(w, c, transparency);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
// `rotWithShape` and `scaled` are written only when set: an absent one reads back off.
|
||||
let mut attrs = if g.rot_with_shape { vec![("rotWithShape", "1")] } else { vec![] };
|
||||
match g.flip {
|
||||
GradientFlip::None => {}
|
||||
GradientFlip::X => attrs.insert(0, ("flip", "x")),
|
||||
GradientFlip::Y => attrs.insert(0, ("flip", "y")),
|
||||
GradientFlip::Xy => attrs.insert(0, ("flip", "xy")),
|
||||
}
|
||||
w.open("a:gradFill", &attrs);
|
||||
w.open("a:gsLst", &[]);
|
||||
for s in &g.stops {
|
||||
w.open("a:gs", &[("pos", &pct(s.pos))]);
|
||||
color(w, s.color, s.transparency);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
let ang = n((angle * 60_000.0).round() as i64);
|
||||
let attrs: &[(&str, &str)] = if scaled { &[("ang", &ang), ("scaled", "1")] } else { &[("ang", &ang)] };
|
||||
w.empty("a:lin", attrs)
|
||||
}
|
||||
GradientKind::Path { shape, focus } => {
|
||||
let path = match shape {
|
||||
PathShape::Circle => "circle",
|
||||
PathShape::Rect => "rect",
|
||||
PathShape::Shape => "shape",
|
||||
};
|
||||
w.open("a:path", &[("path", path)]);
|
||||
let r = rect(focus);
|
||||
w.empty("a:fillToRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
if let Some(t) = g.tile {
|
||||
let r = rect(t);
|
||||
w.empty("a:tileRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,15 +19,10 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
}
|
||||
|
||||
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
|
||||
/// part number `n`. Sized like the renderers draw it: half the page width across, or half the
|
||||
/// page diagonal when diagonal; the height follows the text's length.
|
||||
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
let text = text(wm)?;
|
||||
let (pw, ph) = (page.page_w.clamp(72.0, 4000.0), page.page_h.clamp(72.0, 4000.0));
|
||||
let across = if wm.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = text.chars().count().clamp(1, 200) as f32;
|
||||
let height = (width / (0.55 * chars)).clamp(12.0, 400.0);
|
||||
let [width, height] = wm.frame(page.page_w, page.page_h);
|
||||
// A font name can't break out of the textpath's CSS-like style.
|
||||
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
|
||||
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! What saving a document with many content controls costs (#13).
|
||||
//!
|
||||
//! A form-shaped document: a control per paragraph, inline, or a control around two paragraphs.
|
||||
//! The writer used to look for each paragraph's markers in the whole set of markers, once per
|
||||
//! paragraph, and to compare every control with every other one, so the save was quadratic in the
|
||||
//! number of controls.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
use wordcraft_docx::write;
|
||||
|
||||
/// One paragraph per control, the control inline in it.
|
||||
fn inline_controls(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let props = CharProps::default();
|
||||
for _ in 0..n {
|
||||
let mut p = Paragraph::with_text("value: ", props.clone());
|
||||
let off = p.len();
|
||||
p.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("insert the start marker");
|
||||
let off = p.len();
|
||||
p.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
|
||||
doc.body.push(para_block(p));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Two paragraphs per control, the control written around both of them.
|
||||
fn block_controls(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let props = CharProps::default();
|
||||
for _ in 0..n {
|
||||
let mut open = Paragraph::new();
|
||||
let off = open.len();
|
||||
open.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("insert the start marker");
|
||||
doc.body.push(para_block(open));
|
||||
let mut close = Paragraph::with_text("value", props.clone());
|
||||
let off = close.len();
|
||||
close.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
|
||||
doc.body.push(para_block(close));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// How much more saving the bigger document costs than the smaller one, for the bigger one
|
||||
/// `scale` times the size of the smaller one's: ~`scale` when linear, ~`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; the smaller document is saved `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 {
|
||||
let a = Instant::now();
|
||||
small_scaled();
|
||||
t1 = t1.min(a.elapsed().as_secs_f64() / scale as f64);
|
||||
let b = Instant::now();
|
||||
big();
|
||||
tk = tk.min(b.elapsed().as_secs_f64());
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
/// A document with many inline content controls saves in the time the controls take: the writer
|
||||
/// looked for each paragraph's markers in the whole set of markers, once per paragraph, so a
|
||||
/// document with 32,000 controls took four seconds to save where it takes a tenth of one now.
|
||||
#[test]
|
||||
fn a_document_with_many_inline_content_controls_saves_in_linear_time() {
|
||||
let scale = 4;
|
||||
let small = inline_controls(8_000);
|
||||
let big = inline_controls(8_000 * scale);
|
||||
let (t1, tk, ratio) = cost_ratio(
|
||||
scale,
|
||||
|| {
|
||||
write(&small).expect("write");
|
||||
},
|
||||
|| {
|
||||
write(&big).expect("write");
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// The same for the controls written around whole paragraphs: the writer compared every control
|
||||
/// with every other one to find the ones that clash, so the save was quadratic in their number.
|
||||
#[test]
|
||||
fn a_document_with_many_block_content_controls_saves_in_linear_time() {
|
||||
let scale = 4;
|
||||
let small = block_controls(8_000);
|
||||
let big = block_controls(8_000 * scale);
|
||||
let (t1, tk, ratio) = cost_ratio(
|
||||
scale,
|
||||
|| {
|
||||
write(&small).expect("write");
|
||||
},
|
||||
|| {
|
||||
write(&big).expect("write");
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// The controls are written where they were: the plan that decides whether a control is written
|
||||
/// inline or around whole paragraphs gives the same answer as it did before it was made faster.
|
||||
#[test]
|
||||
fn the_controls_are_written_where_they_were() {
|
||||
// Inline controls: both markers in the same paragraph, so the control is written in it.
|
||||
let bytes = write(&inline_controls(50)).expect("write");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read back");
|
||||
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 50, "every control survives the round trip");
|
||||
for r in &ranges {
|
||||
assert_eq!(r.start.path, r.end.path, "the control is written inline: {:?}", r.start.path);
|
||||
}
|
||||
|
||||
// Block controls: the markers in different paragraphs, so the control is written around them.
|
||||
let bytes = write(&block_controls(50)).expect("write");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read back");
|
||||
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 50, "every control survives the round trip");
|
||||
for r in &ranges {
|
||||
assert_ne!(r.start.path, r.end.path, "the control is written around the paragraphs");
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the parts of a package, in the order they are in it.
|
||||
fn part_names(bytes: &[u8]) -> Vec<String> {
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
z.file_names().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// The body of the document part of a package.
|
||||
fn document_body(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
{
|
||||
use std::io::Read;
|
||||
z.by_name("word/document.xml").expect("the document part").read_to_string(&mut xml).expect("read");
|
||||
}
|
||||
let rest = xml.split_once("<w:body>").expect("the body").1;
|
||||
let body = rest.split_once("</w:body>").expect("the end of the body").0;
|
||||
format!("<w:body>{body}</w:body>")
|
||||
}
|
||||
|
||||
/// A document whose controls are written in an order and a place worth pinning: two inline
|
||||
/// controls in one paragraph, and a control around a whole paragraph.
|
||||
fn pinned_doc() -> Document {
|
||||
let props = CharProps::default();
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
// "before [a] between [b] after", the two controls inline in one paragraph.
|
||||
let mut p = Paragraph::with_text("before ", props.clone());
|
||||
let mut at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::Text { multi_line: false })) }, &props)
|
||||
.expect("the first marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, "inside a", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the first end marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, " between ", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("the second marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, "inside b", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the second end marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, " after", &props).expect("text");
|
||||
doc.body.push(para_block(p));
|
||||
// A control around a whole paragraph.
|
||||
let mut open = Paragraph::new();
|
||||
let at = open.len();
|
||||
open.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("the block marker");
|
||||
doc.body.push(para_block(open));
|
||||
let mut inside = Paragraph::with_text("in the block", props.clone());
|
||||
let at = inside.len();
|
||||
inside.insert_object(at, InlineObject::ControlEnd, &props).expect("the block end marker");
|
||||
doc.body.push(para_block(inside));
|
||||
doc
|
||||
}
|
||||
|
||||
/// The body of the document part the writer writes for [`pinned_doc`], byte for byte: what the
|
||||
/// writer wrote before it was made faster. The counts and the paragraphs in the test above say
|
||||
/// that the controls are where they belong; this says that the rest is where it was too, down to
|
||||
/// the order and the place of the markers inside a paragraph.
|
||||
///
|
||||
/// The body is what the plan decides: which controls are written inline and which around whole
|
||||
/// paragraphs, and where. Nothing outside it can change with the plan, so it is what is pinned.
|
||||
///
|
||||
/// Regenerate it when the writer's output changes on purpose: write [`pinned_doc`] and take the
|
||||
/// body of the document part.
|
||||
#[test]
|
||||
fn the_document_body_is_written_as_it_was() {
|
||||
let bytes = write(&pinned_doc()).expect("write");
|
||||
assert_eq!(document_body(&bytes), PINNED_BODY);
|
||||
// The parts are the same ones, so nothing is added to the package and nothing is lost.
|
||||
assert_eq!(part_names(&bytes), PINNED_PARTS);
|
||||
}
|
||||
|
||||
/// The body of the document part of [`pinned_doc`], as the writer wrote it before it was made
|
||||
/// faster.
|
||||
const PINNED_PARTS: &[&str] = &[
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"word/document.xml",
|
||||
"word/_rels/document.xml.rels",
|
||||
"word/styles.xml",
|
||||
"word/settings.xml",
|
||||
"word/theme/theme1.xml",
|
||||
"docProps/core.xml",
|
||||
"docProps/app.xml",
|
||||
];
|
||||
|
||||
const PINNED_BODY: &str = r#"<w:body><w:p><w:r><w:t xml:space="preserve">before </w:t></w:r><w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside a</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> between </w:t></w:r><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside b</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> after</w:t></w:r></w:p><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:p></w:p><w:p><w:r><w:t xml:space="preserve">in the block</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720" w:num="1"/></w:sectPr></w:body>"#;
|
||||
@@ -245,3 +245,89 @@ fn a_controls_run_properties_are_read_and_kept() {
|
||||
let out = document_xml(&write(&d).unwrap());
|
||||
assert!(out.contains(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{out}");
|
||||
}
|
||||
|
||||
/// Word's Table of Contents control (`w:docPartObj`, #2427) around a TOC the engine's
|
||||
/// heading/entry form doesn't cover (no `TOCHeading`, unstyled entries, the field ending in a
|
||||
/// paragraph of its own) is kept as read: nothing rebuilds it on save.
|
||||
#[test]
|
||||
fn toc_gallery_around_a_free_form_toc_round_trips() {
|
||||
let pr = r#"<w:sdtPr><w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtEndPr/><w:sdtContent>"#;
|
||||
let field = format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:tab/></w:r>{}"#,
|
||||
t("Chapter One"),
|
||||
t("1")
|
||||
);
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let body = format!("<w:sdt>{pr}{}{}{}</w:sdtContent></w:sdt>{}", p(&t("Contents")), p(&field), p(end), p(&t("Chapter One")));
|
||||
let mut bytes = package(&body);
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}"));
|
||||
let close = xml.find("</w:sdtContent></w:sdt>").unwrap();
|
||||
let (contents, fend) = (xml.find(">Contents<").unwrap(), xml.find(r#"w:fldCharType="end""#).unwrap());
|
||||
assert!(open < contents && fend < close, "pass {pass}: {xml}");
|
||||
assert!(close < xml.rfind(">Chapter One<").unwrap(), "the body paragraph stays outside: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control around a TOC in the engine's form (a `TOCHeading`, then TOC-styled entries the
|
||||
/// field spans) still goes to the TOC: written once, around the heading and the entries, and the
|
||||
/// field opens in the heading as References › Update Table expects.
|
||||
#[test]
|
||||
fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &format!("{}{end}", t("Section\t2")))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
assert!(controls(&d).is_empty(), "pass {pass}");
|
||||
let heading = d.body[0].as_para().unwrap();
|
||||
assert!(heading.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Field { instr, .. } if instr.trim().starts_with("TOC"))));
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rich text control around the heading and the first entry of a TOC in the engine's form,
|
||||
/// inside Word's Table of Contents control: the writer doesn't put a TOC control around a heading
|
||||
/// already in a block control, so the outer one is kept as read rather than lost on save.
|
||||
#[test]
|
||||
fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}",
|
||||
sdt(
|
||||
r#"<w:id w:val="31"/><w:richText/>"#,
|
||||
&format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))))
|
||||
),
|
||||
style("TOC2", &format!("{}{end}", t("Section\t2")))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 2, "pass {pass}: {xml}");
|
||||
let (gallery, rich) =
|
||||
(xml.find(&format!("<w:sdtPr>{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#"<w:id w:val="31"/>"#).unwrap());
|
||||
assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}");
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Original nested-picture controls. No native output or external assets are embedded.
|
||||
use std::{io::Write, sync::Arc};
|
||||
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::{Document, InlineObject, Paragraph, PartKind, Pos, StoryRef, TextBody, para_block};
|
||||
|
||||
// Original solid RGB(0,72,192), generated in code for these controls.
|
||||
const BLUE_PNG: &[u8] = &[
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 64, 0, 0, 0, 16, 8, 2, 0, 0, 0, 41, 133, 238, 126, 0, 0, 0, 54, 73, 68,
|
||||
65, 84, 120, 156, 237, 207, 65, 9, 0, 48, 12, 192, 192, 202, 155, 236, 73, 171, 136, 61, 142, 65, 32, 2, 46, 51, 231, 254, 157, 23, 52, 160, 5,
|
||||
13, 104, 65, 3, 90, 208, 128, 22, 52, 160, 5, 13, 104, 65, 3, 90, 240, 214, 2, 129, 33, 32, 61, 59, 114, 68, 120, 0, 0, 0, 0, 73, 69, 78, 68,
|
||||
174, 66, 96, 130,
|
||||
];
|
||||
const INLINE_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex" mc:Ignorable="w14 w15 wp14"><w:body><w:p><w:pPr><w:keepNext w:val="0"/><w:keepLines w:val="0"/><w:pageBreakBefore w:val="0"/><w:widowControl w:val="0"/><w:spacing w:before="0" w:after="0" w:line="320" w:lineRule="exact"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:t xml:space="preserve">2533 Plain | red parent 180 pt wide | saved height 80 pt</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="251658241" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>1270000</wp:posOffset></wp:positionV><wp:extent cx="2286000" cy="1016000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Shape 1" descr=""/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2286000" cy="1016000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="F0F8F0"/></a:solidFill><a:ln w="12700"><a:solidFill><a:srgbClr val="C00000"/></a:solidFill></a:ln></wps:spPr><wps:txbx><w:txbxContent><w:p><w:pPr><w:keepNext w:val="0"/><w:keepLines w:val="0"/><w:widowControl w:val="0"/><w:spacing w:before="0" w:after="0" w:line="320" w:lineRule="exact"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr></w:pPr><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="508000" cy="127000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:docPr id="2" name="Original blue rectangle"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="original-blue.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdIsolationRect"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="127000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:t xml:space="preserve">Parent</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr lIns="0" tIns="0" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:headeLine truncated
|
||||
|
||||
fn picture() -> InlineObject {
|
||||
InlineObject::Image {
|
||||
media: "original-blue.png".into(),
|
||||
w: 40.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
}
|
||||
}
|
||||
fn model_box() -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("original-blue.png".into(), Arc::new(BLUE_PNG.to_vec()));
|
||||
let id = doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Parent", Default::default()))]);
|
||||
doc.parts.get_mut(&id).unwrap().body = TextBody { auto_fit: true, inset: Some([0; 4]), ..Default::default() };
|
||||
doc.insert_object(&Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path(vec![0]), off: 0 }, picture(), &Default::default()).unwrap();
|
||||
let parent = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 180.0,
|
||||
h: 80.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb(240, 248, 240))),
|
||||
stroke: Some(Rgb(192, 0, 0)),
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 100.0, ..Default::default() },
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
doc.insert_object(&Pos::body(0, 0), parent, &Default::default()).unwrap();
|
||||
doc
|
||||
}
|
||||
fn check_box(doc: &Document) {
|
||||
let objects = &doc.body[0].as_para().unwrap().objects;
|
||||
assert_eq!(objects.len(), 1);
|
||||
let InlineObject::Shape { kind, w, h, float, story: Some(id), stroke, stroke_width, .. } = &objects[0] else {
|
||||
panic!("outer textbox lost: {:?}", objects[0]);
|
||||
};
|
||||
assert_eq!((*kind, *w, *h, *stroke, *stroke_width), (ShapeKind::TextBox, 180.0, 80.0, Some(Rgb(192, 0, 0)), 1.0));
|
||||
assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::InFrontOfText, Anchor::Page, Anchor::Page, 72.0, 100.0));
|
||||
let part = &doc.parts[id];
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(part.body.auto_fit);
|
||||
assert_eq!(part.body.inset, Some([0; 4]));
|
||||
assert_eq!(doc.parts.len(), 1);
|
||||
let para = part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(para.text, "\u{fffc}Parent");
|
||||
assert_eq!(para.objects.len(), 1);
|
||||
let InlineObject::Image { w, h, float, media, .. } = ¶.objects[0] else {
|
||||
panic!("inline picture lost");
|
||||
};
|
||||
assert_eq!((*w, *h, float.wrap), (40.0, 10.0, Wrap::Inline));
|
||||
assert_eq!(doc.media[media].as_slice(), BLUE_PNG);
|
||||
}
|
||||
fn package(xml: &str) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let docrels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdIsolationRect" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/isolation-blue.png"/></Relationships>"#;
|
||||
let types = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
for (name, bytes) in [
|
||||
("_rels/.rels", rels.as_slice()),
|
||||
("[Content_Types].xml", types.as_slice()),
|
||||
("word/document.xml", xml.as_bytes()),
|
||||
("word/_rels/document.xml.rels", docrels.as_slice()),
|
||||
("word/media/isolation-blue.png", BLUE_PNG),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
#[test]
|
||||
fn model_nested_picture_roundtrip() {
|
||||
let doc = model_box();
|
||||
check_box(&doc);
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
check_box(&wordcraft_docx::read(&bytes).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn original_inline_xml_keeps_box() {
|
||||
let doc = wordcraft_docx::read(&package(INLINE_XML)).unwrap();
|
||||
check_box(&doc);
|
||||
check_box(&wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn picture_group_canvas_keep_kind() {
|
||||
let group = |canvas| InlineObject::Group {
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
float: Default::default(),
|
||||
ch_w: 100.0,
|
||||
ch_h: 50.0,
|
||||
children: vec![GroupChild { x: 3.0, y: 4.0, obj: picture() }],
|
||||
canvas,
|
||||
};
|
||||
for original in [picture(), group(None), group(Some(CanvasStyle::default()))] {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("original-blue.png".into(), Arc::new(BLUE_PNG.to_vec()));
|
||||
doc.insert_object(&Pos::body(0, 0), original.clone(), &Default::default()).unwrap();
|
||||
let got = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
let object = &got.body[0].as_para().unwrap().objects[0];
|
||||
match (&original, object) {
|
||||
(InlineObject::Image { .. }, InlineObject::Image { w, h, media, .. }) => {
|
||||
assert_eq!((*w, *h), (40.0, 10.0));
|
||||
assert_eq!(got.media[media].as_slice(), BLUE_PNG);
|
||||
}
|
||||
(InlineObject::Group { canvas: expected, .. }, InlineObject::Group { w, h, children, canvas, .. }) => {
|
||||
assert_eq!((*w, *h), (100.0, 50.0));
|
||||
assert_eq!(canvas.is_some(), expected.is_some());
|
||||
assert_eq!(children.len(), 1);
|
||||
let child = &children[0];
|
||||
assert_eq!((child.x, child.y), (3.0, 4.0));
|
||||
let InlineObject::Image { w, h, media, .. } = &child.obj else {
|
||||
panic!("picture member changed kind");
|
||||
};
|
||||
assert_eq!((*w, *h), (40.0, 10.0));
|
||||
assert_eq!(got.media[media].as_slice(), BLUE_PNG);
|
||||
}
|
||||
_ => panic!("payload changed kind: {object:?}"),
|
||||
}
|
||||
assert!(got.parts.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_picture_fill_keeps_image() {
|
||||
// Keep the existing WPS picture-fill fallback, distinct from a nested picture.
|
||||
let start = INLINE_XML.find("<wps:spPr>").unwrap();
|
||||
let end = INLINE_XML.find("</wps:wsp>").unwrap();
|
||||
let payload = r#"<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2286000" cy="1016000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:blipFill><a:blip r:embed="rIdIsolationRect"/><a:stretch><a:fillRect/></a:stretch></a:blipFill></wps:spPr><wps:bodyPr/>"#;
|
||||
let xml = format!("{}{}{}", &INLINE_XML[..start], payload, &INLINE_XML[end..]);
|
||||
let doc = wordcraft_docx::read(&package(&xml)).unwrap();
|
||||
assert!(doc.parts.is_empty());
|
||||
let objects = &doc.body[0].as_para().unwrap().objects;
|
||||
assert_eq!(objects.len(), 1);
|
||||
let InlineObject::Image { w, h, media, float, .. } = &objects[0] else {
|
||||
panic!("own picture fill lost: {:?}", objects[0]);
|
||||
};
|
||||
assert_eq!((*w, *h), (180.0, 80.0));
|
||||
assert_eq!((float.wrap, float.x, float.y), (Wrap::InFrontOfText, 72.0, 100.0));
|
||||
assert_eq!(doc.media[media].as_slice(), BLUE_PNG);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! East Asian language attribute of a run (`w:lang/@w:eastAsia`, ISO/IEC 29500-1 §17.3.2.20).
|
||||
//! Verifies that saving DOCX documents preserves the East Asian language of `w:lang`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut parts = vec![("_rels/.rels", root.to_string()), ("word/document.xml", doc)];
|
||||
if let Some(s) = styles {
|
||||
parts.push(("word/_rels/document.xml.rels", doc_rels.to_string()));
|
||||
parts.push((
|
||||
"word/styles.xml",
|
||||
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in &parts {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(text: &str, rpr: &str) -> String {
|
||||
format!(r#"<w:r><w:rPr>{rpr}</w:rPr><w:t>{text}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_lang_survives_a_save() {
|
||||
let body = format!(
|
||||
r#"<w:p>{}{}</w:p>"#,
|
||||
run("Hello", r#"<w:lang w:val="en-US" w:eastAsia="ja-JP" w:bidi="ar-SA"/>"#),
|
||||
run("中文", r#"<w:lang w:eastAsia="zh-CN"/>"#)
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
|
||||
let r0 = &p.runs[0];
|
||||
assert_eq!(r0.props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(r0.props.lang_ea.as_deref(), Some("ja-JP"));
|
||||
assert_eq!(r0.props.lang_bidi.as_deref(), Some("ar-SA"));
|
||||
|
||||
let r1 = &p.runs[1];
|
||||
assert_eq!(r1.props.lang, None);
|
||||
assert_eq!(r1.props.lang_ea.as_deref(), Some("zh-CN"));
|
||||
assert_eq!(r1.props.lang_bidi, None);
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = part(&out, "word/document.xml");
|
||||
assert!(xml.contains(r#"w:eastAsia="ja-JP""#), "{xml}");
|
||||
assert!(xml.contains(r#"w:eastAsia="zh-CN""#), "{xml}");
|
||||
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
let back_p = back.body[0].as_para().unwrap();
|
||||
assert_eq!(back_p.runs[0].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(back_p.runs[0].props.lang_ea.as_deref(), Some("ja-JP"));
|
||||
assert_eq!(back_p.runs[0].props.lang_bidi.as_deref(), Some("ar-SA"));
|
||||
assert_eq!(back_p.runs[1].props.lang_ea.as_deref(), Some("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_lang_style_and_whitespace_trim() {
|
||||
let styles =
|
||||
r#"<w:style w:type="character" w:styleId="CJKLang"><w:name w:val="CJK Language"/><w:rPr><w:lang w:eastAsia=" ko-KR "/></w:rPr></w:style>"#;
|
||||
let body = format!(r#"<w:p>{}</w:p>"#, run("한국어", r#"<w:rStyle w:val="CJKLang"/>"#));
|
||||
let d = wordcraft_docx::read(&docx(&body, Some(styles))).unwrap();
|
||||
|
||||
let style = d.styles.get("CJKLang").unwrap();
|
||||
assert_eq!(style.chr.lang_ea.as_deref(), Some("ko-KR"));
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let styles_xml = part(&out, "word/styles.xml");
|
||||
assert!(styles_xml.contains(r#"w:eastAsia="ko-KR""#), "{styles_xml}");
|
||||
}
|
||||
+395
-21
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
|
||||
@@ -1012,6 +1013,25 @@ fn anchor_alignment_reference_areas_and_distances() {
|
||||
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_stacking_order_is_read_and_kept() {
|
||||
let shape = |z: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {z}><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="127000" cy="127000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
};
|
||||
let body = format!("<w:p>{}{}{}</w:p>", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape(""));
|
||||
let zs = |d: &Document| -> Vec<u32> {
|
||||
paras(d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.z) } else { None }).collect()
|
||||
};
|
||||
let d = read_body(&body);
|
||||
assert_eq!(zs(&d), [251660288, 251657214, 0]);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let z = zs(&again);
|
||||
assert_eq!(&z[..2], &[251660288, 251657214]);
|
||||
assert!(z[2] > 0, "an object without one gets an order on saving: {z:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
|
||||
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||||
@@ -1623,6 +1643,46 @@ fn ole_object_round_trips() {
|
||||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_line_round_trips() {
|
||||
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
|
||||
// with an `o:signatureline` that makes it signable in Word. Saving keeps it a signature line,
|
||||
// not a plain picture; a VML picture without one still becomes a DrawingML picture.
|
||||
let body = r##"<w:p><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype><v:shape id="_x0000_i1025" type="#_x0000_t75" alt="Signature line" style="width:192pt;height:96pt"><v:imagedata r:id="rId8" o:title=""/><o:lock v:ext="edit" ungrouping="t" rotation="t" cropping="t" verticies="t" text="t" grouping="t"/><o:signatureline v:ext="edit" id="{00000000-0000-0000-0000-000000000001}" provid="{00000000-0000-0000-0000-000000000000}" o:suggestedsigner="A. Example" o:suggestedsigner2="Director" showsigndate="f" issignatureline="t"/></v:shape></w:pict></w:r><w:r><w:pict><v:shape style="width:72pt;height:36pt" alt="plain"><v:imagedata r:id="rId8" o:title=""/></v:shape></w:pict></w:r></w:p>"##;
|
||||
let picture = "\u{89}PNG stand-in";
|
||||
let types =
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
let bytes = docx(body, &[("rId8", "image", "media/image1.png")], &[("[Content_Types].xml", types), ("word/media/image1.png", picture)]);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let objects = ¶s(&d)[0].objects;
|
||||
let [InlineObject::Image { w, alt, ole: Some(_), .. }, InlineObject::Image { ole: None, .. }] = objects.as_slice() else { panic!("{objects:?}") };
|
||||
assert_eq!((*w, alt.as_str()), (192.0, "Signature line"));
|
||||
|
||||
// Resized in WordCraft.
|
||||
let mut doc = d.clone();
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
|
||||
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
|
||||
{
|
||||
(*w, *h) = (144.0, 72.0);
|
||||
}
|
||||
let out = wordcraft_docx::write(&doc).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner"), ["A. Example"]);
|
||||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner2"), ["Director"]);
|
||||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
|
||||
assert_eq!(attr_values(&body, "v:shapetype", "id"), ["_x0000_t75"]);
|
||||
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&rels, &pic_id);
|
||||
assert!(kind.ends_with("/image"), "{kind}");
|
||||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||||
assert_eq!(body.matches("<w:drawing>").count(), 1, "only the plain picture becomes a DrawingML picture");
|
||||
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_embedded_relationships_stay_bounded() {
|
||||
// The chart relates to itself, the main document, a relationships part and a missing part;
|
||||
@@ -2138,7 +2198,7 @@ fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
|
||||
assert_eq!(objs.len(), 5);
|
||||
let shape = |i: usize| match &objs[i] {
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
|
||||
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
|
||||
(*kind, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, extra.arrow_end, story.is_some())
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
@@ -2211,7 +2271,10 @@ fn vml_lines_and_rectangles() {
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
assert!(matches!(
|
||||
&objs[1],
|
||||
InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Fill::Solid { color: Rgb(255, 0, 0), .. }), stroke: None, w: 30.0, h: 20.0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
/// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13).
|
||||
@@ -2263,7 +2326,7 @@ fn vml_preset_shapes_are_drawn() {
|
||||
let ps = paras(&d);
|
||||
let shape = |i: usize| match ps[i].objects.first() {
|
||||
Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. }) => {
|
||||
Some((*kind, *w, *h, *fill, *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
|
||||
Some((*kind, *w, *h, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -2339,7 +2402,14 @@ fn vml_preset_shape_survives_saving() {
|
||||
assert!(
|
||||
matches!(
|
||||
¶s(&again)[0].objects[..],
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 120.0, h: 40.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), stroke: Some(Rgb::BLACK), .. }]
|
||||
[InlineObject::Shape {
|
||||
kind: ShapeKind::Arrow,
|
||||
w: 120.0,
|
||||
h: 40.0,
|
||||
fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
..
|
||||
}]
|
||||
),
|
||||
"{:?}",
|
||||
paras(&again)[0].objects
|
||||
@@ -2360,7 +2430,7 @@ fn vml_nearest_preset_shape_survives_saving() {
|
||||
assert!(
|
||||
matches!(
|
||||
¶s(&again)[0].objects[..],
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), .. }]
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
|
||||
),
|
||||
"{:?}",
|
||||
paras(&again)[0].objects
|
||||
@@ -2417,7 +2487,7 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
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),
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, fill.as_ref().map(Fill::main_color), freeform.is_some(), *story),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (kind, fill, free, _) = shape(0);
|
||||
@@ -2469,7 +2539,7 @@ fn drawingml_shapes_survive_saving() {
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
(*kind, *w, *h, fill.clone(), *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
@@ -2492,7 +2562,7 @@ fn drawingml_shapes_survive_saving() {
|
||||
]
|
||||
);
|
||||
for s in &read[..5] {
|
||||
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0);
|
||||
assert_eq!(s.3, Some(wordcraft_doc::fill::Fill::solid(accent)), "{:?} is filled from the theme", s.0);
|
||||
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
|
||||
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
|
||||
}
|
||||
@@ -3109,6 +3179,39 @@ fn toc_entry_links_lose_the_hyperlink_style() {
|
||||
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
|
||||
}
|
||||
|
||||
/// Localized Word (Spanish: `Hipervnculo`) and older files (`Hyperlnk`) give the built-in
|
||||
/// Hyperlink character style another id; its name stays "Hyperlink". TOC entries carrying it are
|
||||
/// plain too (#3239: entries showed blue).
|
||||
#[test]
|
||||
fn toc_entry_links_lose_a_localized_hyperlink_style() {
|
||||
for id in ["Hipervnculo", "Hyperlnk"] {
|
||||
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="{id}"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let link = |t: &str| {
|
||||
format!(
|
||||
r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="{id}"/></w:rPr><w:t>{t}</w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>"#
|
||||
)
|
||||
};
|
||||
let body = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:sdtContent></w:sdt><w:p>{}</w:p><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>"#,
|
||||
link("entry"),
|
||||
link("plain")
|
||||
);
|
||||
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")), "{id}: a TOC entry: plain, still a link");
|
||||
assert_eq!(run("plain").style.as_deref(), Some(id), "{id}: 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
|
||||
@@ -3190,6 +3293,45 @@ fn text_frames_read_and_round_trip() {
|
||||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||||
}
|
||||
|
||||
/// #2360: a `w:framePr` in the document defaults, the sizeless top-left one Apple Pages writes,
|
||||
/// frames no paragraph, as in Word: a header's title and the body's text stay in the text instead
|
||||
/// of moving to the page's corner, and the default isn't written back. The defaults' other
|
||||
/// properties are kept, and a style's own frame still applies.
|
||||
#[test]
|
||||
fn a_frame_in_the_paragraph_defaults_frames_nothing() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr><w:framePr w:anchorLock="0" w:w="0" w:h="0" w:vSpace="0" w:hSpace="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults><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="Framed"><w:name w:val="Framed"/><w:pPr><w:framePr w:wrap="around" w:hAnchor="margin" w:vAnchor="text" w:xAlign="right" w:y="1"/></w:pPr></w:style></w:styles>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Title</w:t></w:r></w:p></w:hdr>"#);
|
||||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Boxed</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||||
let bytes = docx(
|
||||
body,
|
||||
&[("rIdS", "styles", "styles.xml"), ("rIdH", "header", "header1.xml")],
|
||||
&[("word/styles.xml", &styles), ("word/header1.xml", &header)],
|
||||
);
|
||||
let check = |d: &Document| {
|
||||
assert!(d.styles.default_para.frame.is_none(), "{:?}", d.styles.default_para.frame);
|
||||
assert_eq!(d.styles.default_para.space_after, Some(6.0), "the defaults' spacing is kept");
|
||||
let frame = |p: &Paragraph| d.styles.resolve_para(&p.props).frame;
|
||||
let body = paras(d);
|
||||
assert_eq!(body[0].text, "Body");
|
||||
assert!(frame(body[0]).is_none(), "body text is not a frame");
|
||||
let boxed = frame(body[1]).expect("a style's frame still applies");
|
||||
assert_eq!((boxed.h_anchor.as_str(), boxed.x_align.as_deref()), ("margin", Some("right")));
|
||||
let hid = d.last_section.headers.default.expect("a header");
|
||||
let title = d.parts.get(&hid).unwrap().blocks.iter().find_map(|b| b.as_para()).expect("the title");
|
||||
assert_eq!(title.text, "Title");
|
||||
assert!(frame(title).is_none(), "the header's title is not a frame");
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = text(&unzip(&saved), "word/styles.xml");
|
||||
let defaults = xml.split("</w:docDefaults>").next().unwrap_or_default();
|
||||
assert!(!defaults.contains("w:framePr"), "the default frame isn't written back: {defaults}");
|
||||
check(&wordcraft_docx::read(&saved).unwrap());
|
||||
}
|
||||
|
||||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
|
||||
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
|
||||
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
|
||||
@@ -3207,7 +3349,7 @@ fn vml_freeform_rule_in_header() {
|
||||
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
|
||||
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||||
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
|
||||
assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||||
assert_eq!((*kind, fill.as_ref().map(Fill::main_color), *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||||
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
|
||||
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
|
||||
assert!(near(float.y, 30.0), "{float:?}");
|
||||
@@ -3277,21 +3419,56 @@ fn hostile_vml_paths_stay_finite() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`);
|
||||
/// when that is empty it names nothing, and the run keeps the font it inherits.
|
||||
/// Measured in Word for Mac: a `majorBidi`/`minorBidi` theme slot names the theme's font for the
|
||||
/// script of the settings' `w:themeFontLang/@w:bidi` (the Latin font for a Latin language), else
|
||||
/// the theme's `a:cs`, else Times New Roman; with no bidi language there, Times New Roman whatever
|
||||
/// the theme says. The run's own `w:lang` and the inherited fonts play no part. Papers set in
|
||||
/// `majorBidi` with Office's usual theme (`Arab` → Times New Roman) came out in Calibri Light.
|
||||
#[test]
|
||||
fn bidi_theme_slots_name_the_complex_script_font() {
|
||||
let font = |cs: &str| {
|
||||
fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
let tnr = "Times New Roman";
|
||||
// (themeFontLang, a:cs, Arab entries present, run lang) → (majorBidi, minorBidi)
|
||||
let cases: &[(Option<&str>, &str, bool, &str, [&str; 2])] = &[
|
||||
(None, "", true, "", [tnr, tnr]),
|
||||
(None, "Arial", true, r#"<w:lang w:bidi="ar-SA"/>"#, [tnr, tnr]),
|
||||
(Some(r#"w:val="en-US""#), "Arial", true, "", [tnr, tnr]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "", true, r#"<w:lang w:bidi="he-IL"/>"#, ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="he-IL""#), "", true, "", ["Courier New", "Comic Sans MS"]),
|
||||
(Some(r#"w:bidi="ur-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="pa-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="pa-IN""#), "Arial", true, "", ["Arial", "Arial"]),
|
||||
(Some(r#"w:bidi="fr-FR""#), "Arial", true, "", ["Verdana", "Impact"]),
|
||||
(Some(r#"w:bidi="th-TH""#), "Arial", true, "", ["Arial", "Arial"]),
|
||||
(Some(r#"w:bidi="th-TH""#), "", true, "", [tnr, tnr]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "Arial", false, "", ["Arial", "Arial"]),
|
||||
];
|
||||
for (i, (lang, cs, arab, run_lang, want)) in cases.iter().enumerate() {
|
||||
let script = |f: &str| if *arab { format!(r#"<a:font script="Arab" typeface="{f}"/>"#) } else { String::new() };
|
||||
let theme = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Cambria"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Verdana"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Courier New"/></a:majorFont><a:minorFont><a:latin typeface="Impact"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Comic Sans MS"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#,
|
||||
script("Georgia"),
|
||||
script("Trebuchet MS")
|
||||
);
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml")];
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="minorBidi" w:hAnsiTheme="minorBidi"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/theme/theme1.xml", &theme)])).unwrap();
|
||||
paras(&d)[0].runs[0].props.font.clone()
|
||||
};
|
||||
assert_eq!(font(""), None);
|
||||
assert_eq!(font("Times New Roman").as_deref(), Some("Times New Roman"));
|
||||
let font_lang = lang.map(|l| format!("<w:themeFontLang {l}/>")).unwrap_or_default();
|
||||
let settings = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}>{font_lang}</w:settings>"#);
|
||||
let styles = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles {W_NS}><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Andale Mono" w:hAnsi="Andale Mono" w:cs="Andale Mono"/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr></w:rPrDefault></w:docDefaults></w:styles>"#
|
||||
);
|
||||
let run = |slot: &str| {
|
||||
format!(r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="{slot}" w:hAnsiTheme="{slot}"/>{run_lang}</w:rPr><w:t>x</w:t></w:r></w:p>"#)
|
||||
};
|
||||
let body = [run("majorBidi"), run("minorBidi"), run("majorHAnsi")].concat();
|
||||
let rels = [
|
||||
("rId7", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml"),
|
||||
("rId8", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"),
|
||||
("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml"),
|
||||
];
|
||||
let parts = [("word/theme/theme1.xml", theme.as_str()), ("word/settings.xml", settings.as_str()), ("word/styles.xml", styles.as_str())];
|
||||
let d = wordcraft_docx::read(&docx(&body, &rels, &parts)).unwrap();
|
||||
let fonts: Vec<_> = paras(&d).iter().map(|p| p.runs[0].props.font.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(fonts, [want[0], want[1], "Verdana"], "case {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
@@ -3540,3 +3717,200 @@ fn equation_in_alternate_content_is_read() {
|
||||
assert_eq!(equations(p), vec![("x+1".to_string(), false)]);
|
||||
assert!(!p.plain_text().contains("[picture]"));
|
||||
}
|
||||
|
||||
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
|
||||
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
|
||||
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
|
||||
/// file states it and survives saving.
|
||||
#[test]
|
||||
fn shape_gradients_and_transparency_are_read_and_survive_saving() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fmtScheme name="T"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="50000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="000000"/></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#;
|
||||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||||
let wsp = |sppr: &str, style: &str| anchored_wsp("S", 914400, 914400, &format!("{rect}{sppr}"), style, "<wps:cNvSpPr/>");
|
||||
let fill_ref = |idx: u32| {
|
||||
format!(
|
||||
r#"<wps:style><a:lnRef idx="0"><a:srgbClr val="4080C0"/></a:lnRef><a:fillRef idx="{idx}"><a:srgbClr val="4080C0"/></a:fillRef><a:effectRef idx="0"><a:srgbClr val="4080C0"/></a:effectRef><a:fontRef idx="minor"/></wps:style>"#
|
||||
)
|
||||
};
|
||||
let gs = |pos: u32, c: &str| format!(r#"<a:gs pos="{pos}"><a:srgbClr val="{c}"/></a:gs>"#);
|
||||
let shapes = [
|
||||
// A: 30%-opaque white.
|
||||
wsp(r#"<a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30000"/></a:srgbClr></a:solidFill>"#, ""),
|
||||
// B: two stops top to bottom, the second 40% opaque.
|
||||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}<a:gs pos="100000"><a:srgbClr val="0000FF"><a:alpha val="40000"/></a:srgbClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="1"/></a:gradFill>"#, gs(0, "FF0000")), ""),
|
||||
// C: three stops at 45°.
|
||||
wsp(&format!(r#"<a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="2700000" scaled="0"/></a:gradFill>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||||
// D: circular from the centre.
|
||||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}{}</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||||
// E: rectangular from a corner, flipped and tiled, not turning with the shape.
|
||||
wsp(&format!(r#"<a:gradFill flip="xy" rotWithShape="0"><a:gsLst>{}{}</a:gsLst><a:path path="rect"><a:fillToRect l="100000" t="100000" r="0" b="0"/></a:path><a:tileRect l="-100000" t="-100000" r="0" b="0"/></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||||
// F, G: the theme's fill style 3 and background style 3 in the reference's colour.
|
||||
wsp("", &fill_ref(3)),
|
||||
wsp("", &fill_ref(1003)),
|
||||
// H: an outline gradient.
|
||||
wsp(&format!(r#"<a:noFill/><a:ln w="12700"><a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></a:ln>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||||
]
|
||||
.concat();
|
||||
let member = |id: u32, paint: &str| {
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>{rect}{paint}</wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||||
)
|
||||
};
|
||||
// I: a group with a gradient fill; one member takes it (`a:grpFill`), one has its own, and
|
||||
// one in a nested group without a fill of its own takes the outer group's.
|
||||
let group = format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="9" name="G"/><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="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm><a:gradFill><a:gsLst>{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></wpg:grpSpPr>{}{}<wpg:grpSp><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm></wpg:grpSpPr>{}</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#,
|
||||
gs(0, "112233"),
|
||||
gs(100000, "445566"),
|
||||
member(10, "<a:grpFill/>"),
|
||||
member(11, r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#),
|
||||
member(12, "<a:grpFill/>")
|
||||
);
|
||||
// VML: a linear gradient to `color2`, half opaque.
|
||||
let vml = r##"<w:r><w:pict><v:rect style="width:72pt;height:72pt" fillcolor="#ff0000" stroked="f"><v:fill type="gradient" color2="#0000ff" opacity="0.5"/></v:rect></w:pict></w:r>"##;
|
||||
let bytes = docx(&format!("<w:p>{shapes}{group}{vml}</w:p>"), &[("rId2", "theme", "theme/theme1.xml")], &[("word/theme/theme1.xml", theme)]);
|
||||
|
||||
let stop = |pos: f32, c: Rgb, transparency: f32| GradStop { pos, color: c, transparency };
|
||||
let (red, green, blue, white, black) = (Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb::WHITE, Rgb::BLACK);
|
||||
let grad = |stops: Vec<GradStop>, kind: GradientKind, rot: bool| {
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: rot, flip: GradientFlip::None }))
|
||||
};
|
||||
let lin = |angle: f32, scaled: bool| GradientKind::Linear { angle, scaled };
|
||||
let centre = GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] };
|
||||
let ph = Rgb(0x40, 0x80, 0xC0);
|
||||
let expected = [
|
||||
Some(Fill::Solid { color: white, transparency: 70.0 }),
|
||||
Some(grad(vec![stop(0.0, red, 0.0), stop(100.0, blue, 60.0)], lin(90.0, true), true)),
|
||||
Some(grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(45.0, false), false)),
|
||||
Some(grad(vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||||
Some(Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)],
|
||||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [100.0, 100.0, 0.0, 0.0] },
|
||||
tile: Some([-100.0, -100.0, 0.0, 0.0]),
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
}))),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||||
None,
|
||||
];
|
||||
let check = |d: &Document| {
|
||||
let objs = ¶s(d)[0].objects;
|
||||
assert_eq!(objs.len(), expected.len() + 2);
|
||||
for (i, want) in expected.iter().enumerate() {
|
||||
let InlineObject::Shape { fill, .. } = &objs[i] else { panic!("{:?}", objs[i]) };
|
||||
assert_eq!(fill, want, "shape {i}");
|
||||
}
|
||||
let InlineObject::Shape { stroke, extra, .. } = &objs[7] else { panic!() };
|
||||
let outline = grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(0.0, false), false);
|
||||
assert_eq!((*stroke, extra.outline_fill.as_ref()), (Some(green), Some(&outline)), "outline drawn in its middle stop");
|
||||
let InlineObject::Group { children, .. } = &objs[8] else { panic!("{:?}", objs[8]) };
|
||||
let fills: Vec<_> = children.iter().map(|c| if let InlineObject::Shape { fill, .. } = &c.obj { fill.clone() } else { None }).collect();
|
||||
let group_fill = grad(vec![stop(0.0, Rgb(0x11, 0x22, 0x33), 0.0), stop(100.0, Rgb(0x44, 0x55, 0x66), 0.0)], lin(0.0, false), false);
|
||||
assert_eq!(fills, [Some(group_fill.clone()), Some(Fill::solid(green)), Some(group_fill)], "a:grpFill takes the group's fill");
|
||||
let InlineObject::Shape { fill, .. } = &objs[9] else { panic!("{:?}", objs[9]) };
|
||||
assert_eq!(fill.as_ref(), Some(&grad(vec![stop(0.0, red, 50.0), stop(100.0, blue, 0.0)], lin(90.0, false), true)), "VML gradient");
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// #3415: a table style's row and column band sizes (`w:tblStyleRowBandSize`,
|
||||
/// `w:tblStyleColBandSize`, ECMA-376 §17.7.6.5 and §17.7.6.7) are read, zero included, and left
|
||||
/// unset when the file has none, which is how Word reads it: no bands. A style based on one with
|
||||
/// sizes inherits them.
|
||||
#[test]
|
||||
fn table_style_band_sizes_read_as_word_reads_them() {
|
||||
let band = r#"<w:tblStylePr w:type="band1Horz"><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C1E4F5"/></w:tcPr></w:tblStylePr>"#;
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
|
||||
<w:style w:type="table" w:styleId="Missing"><w:name w:val="Missing"/><w:basedOn w:val="TableNormal"/>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="One"><w:name w:val="One"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/></w:tblPr>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="Zero"><w:name w:val="Zero"/><w:basedOn w:val="One"/><w:tblPr><w:tblStyleRowBandSize w:val="0"/></w:tblPr></w:style>
|
||||
<w:style w:type="table" w:styleId="Child"><w:name w:val="Child"/><w:basedOn w:val="One"/>{band}</w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let sizes = |id: &str| d.styles.table_style(id).map(|t| (t.parts.band_size, t.parts.col_band_size));
|
||||
assert_eq!(sizes("Missing"), Some((None, None)), "no sizes: no bands, as in Word");
|
||||
assert_eq!(sizes("One"), Some((Some(1), Some(1))));
|
||||
assert_eq!(sizes("Zero"), Some((Some(0), Some(1))));
|
||||
assert_eq!(sizes("Child"), Some((Some(1), Some(1))), "inherited");
|
||||
// Written back as read: still no sizes where there were none.
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let style = |id: &str| {
|
||||
let start = xml.find(&format!(r#"w:styleId="{id}""#)).unwrap();
|
||||
xml[start..start + xml[start..].find("</w:style>").unwrap()].to_string()
|
||||
};
|
||||
assert!(!style("Missing").contains("BandSize"), "{}", style("Missing"));
|
||||
assert!(style("One").contains(r#"<w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{}", style("One"));
|
||||
assert!(style("Zero").contains(r#"<w:tblStyleRowBandSize w:val="0"/>"#), "{}", style("Zero"));
|
||||
}
|
||||
|
||||
/// #3415: a document saved with one of our banded table styles tells Word its band sizes, so Word
|
||||
/// shades the same rows we do (it reads a style without them as having no bands).
|
||||
#[test]
|
||||
fn our_banded_table_styles_write_their_band_sizes() {
|
||||
let d = Document::new();
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let start = xml.find(r#"w:styleId="GridTable4AccentBlue""#).expect("Grid Table 4 – Blue is written");
|
||||
let style = &xml[start..start + xml[start..].find("</w:style>").unwrap()];
|
||||
assert!(style.contains(r#"<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{style}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_shape_wrap_side_and_anchor_case_insensitivity() {
|
||||
let body = r##"<w:p>
|
||||
<w:r>
|
||||
<w:pict xmlns:w10="urn:schemas-microsoft-com:office:word">
|
||||
<v:rect style="position:absolute;width:100pt;height:50pt;mso-position-horizontal-relative:Page;mso-position-vertical-relative:Margin" filled="False" stroked="t">
|
||||
<w10:wrap type="Square" side="left"/>
|
||||
</v:rect>
|
||||
</w:pict>
|
||||
</w:r>
|
||||
</w:p>"##;
|
||||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let shape = match p.objects.first() {
|
||||
Some(InlineObject::Shape { fill, float, .. }) => (fill, float),
|
||||
o => panic!("expected shape, got {o:?}"),
|
||||
};
|
||||
assert_eq!(*shape.0, None);
|
||||
assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square);
|
||||
assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left);
|
||||
assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page);
|
||||
assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_textbox_style_anchor_and_fit() {
|
||||
let body = r#"<w:p>
|
||||
<w:r>
|
||||
<w:pict>
|
||||
<v:rect style="width:100pt;height:50pt">
|
||||
<v:textbox style="v-text-anchor:Middle;mso-fit-shape-to-text:True">
|
||||
<w:txbxContent>
|
||||
<w:p><w:r><w:t>Centered text</w:t></w:r></w:p>
|
||||
</w:txbxContent>
|
||||
</v:textbox>
|
||||
</v:rect>
|
||||
</w:pict>
|
||||
</w:r>
|
||||
</w:p>"#;
|
||||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
let story_id = match p.objects.first() {
|
||||
Some(InlineObject::Shape { story: Some(id), .. }) => *id,
|
||||
o => panic!("expected shape with story, got {o:?}"),
|
||||
};
|
||||
let tb = doc.parts.get(&story_id).unwrap();
|
||||
assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center);
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Round-trip tests: build documents programmatically → write → read → compare.
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::Fill;
|
||||
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
@@ -22,6 +23,21 @@ fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
/// `doc` with the stacking order of its body's floating objects cleared: saving gives objects
|
||||
/// without one an order (`relativeHeight`), so tests about other properties compare without it.
|
||||
fn unstacked(mut doc: Document) -> Document {
|
||||
for b in &mut doc.body {
|
||||
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let Some(f) = o.float_mut() {
|
||||
f.z = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
id.and_then(|i| doc.parts.get(&i))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
|
||||
@@ -113,6 +129,7 @@ fn every_char_prop_round_trips() {
|
||||
bold_cs: Some(false),
|
||||
italic_cs: Some(true),
|
||||
lang_bidi: Some("fa-IR".into()),
|
||||
lang_ea: Some("ja-JP".into()),
|
||||
east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout {
|
||||
id: Some(3),
|
||||
combine: Some(true),
|
||||
@@ -457,7 +474,7 @@ fn images_round_trip() {
|
||||
p.insert_object(end, f.clone(), &CharProps::default()).unwrap();
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let got = paras(&r)[0];
|
||||
let mut expect = vec![inline];
|
||||
expect.extend(floats);
|
||||
@@ -1101,7 +1118,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: Some(Rgb(255, 255, 200)),
|
||||
fill: Some(Fill::solid(Rgb(255, 255, 200))),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
|
||||
@@ -1139,8 +1156,8 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
match &got[0].objects[0] {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
|
||||
assert_eq!(
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
|
||||
(*kind, *w, *h, fill.clone(), *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Fill::solid(Rgb(255, 255, 200))), Some(Rgb(0, 0, 0)), 1.0)
|
||||
);
|
||||
assert_eq!(float.wrap, Wrap::Square);
|
||||
assert_eq!(part_text(&r, *story), "Inside the box");
|
||||
@@ -1174,7 +1191,7 @@ fn groups_round_trip() {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 50.0,
|
||||
h: 30.0,
|
||||
fill: Some(Rgb(200, 0, 0)),
|
||||
fill: Some(Fill::solid(Rgb(200, 0, 0))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
@@ -1199,7 +1216,7 @@ fn groups_round_trip() {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 100.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(255, 255, 255)),
|
||||
fill: Some(Fill::solid(Rgb(255, 255, 255))),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
@@ -1226,7 +1243,7 @@ fn groups_round_trip() {
|
||||
let end = p.len();
|
||||
p.insert_object(end, group, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let got = paras(&r);
|
||||
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
|
||||
@@ -1261,7 +1278,7 @@ fn drawing_canvas_round_trips() {
|
||||
kind: ShapeKind::Star,
|
||||
w: 72.0,
|
||||
h: 72.0,
|
||||
fill: Some(Rgb(0xE0, 0xA0, 0x20)),
|
||||
fill: Some(Fill::solid(Rgb(0xE0, 0xA0, 0x20))),
|
||||
stroke: Some(Rgb(0x40, 0x40, 0x40)),
|
||||
stroke_width: 1.0,
|
||||
float: Float::default(),
|
||||
@@ -1270,7 +1287,11 @@ fn drawing_canvas_round_trips() {
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 };
|
||||
let style = CanvasStyle {
|
||||
fill: Some(Fill::Solid { color: Rgb(0xF0, 0xF4, 0xFF), transparency: 25.0 }),
|
||||
stroke: Some(Rgb(0x20, 0x40, 0x80)),
|
||||
stroke_width: 1.5,
|
||||
};
|
||||
let canvas = InlineObject::Group {
|
||||
w: 432.0,
|
||||
h: 216.0,
|
||||
@@ -1278,7 +1299,7 @@ fn drawing_canvas_round_trips() {
|
||||
ch_w: 432.0,
|
||||
ch_h: 216.0,
|
||||
children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }],
|
||||
canvas: Some(style),
|
||||
canvas: Some(style.clone()),
|
||||
};
|
||||
let mut p = Paragraph::with_text("drawn ", CharProps::default());
|
||||
let end = p.len();
|
||||
@@ -1309,7 +1330,7 @@ fn rotated_drawing_canvas_round_trips() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { rot: 15.0, ..Default::default() },
|
||||
@@ -1325,7 +1346,7 @@ fn rotated_drawing_canvas_round_trips() {
|
||||
ch_w: 200.0,
|
||||
ch_h: 100.0,
|
||||
children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }],
|
||||
canvas: Some(CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }),
|
||||
canvas: Some(CanvasStyle { fill: Some(Fill::solid(Rgb(0xF0, 0xF4, 0xFF))), stroke: None, stroke_width: 0.0 }),
|
||||
};
|
||||
let square = Float { wrap: Wrap::Square, ..Default::default() };
|
||||
let mut p = Paragraph::with_text("c", CharProps::default());
|
||||
@@ -1511,7 +1532,10 @@ fn watermark_roundtrips() {
|
||||
color: Rgb(0x80, 0x10, 0x20),
|
||||
diagonal: false,
|
||||
semitransparent: false,
|
||||
size: None,
|
||||
},
|
||||
// A box of its own (not Word's default for the page) is kept.
|
||||
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
|
||||
] {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
@@ -1911,11 +1935,11 @@ fn list_level_overrides_round_trip() {
|
||||
#[test]
|
||||
fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
|
||||
let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape {
|
||||
let shape = |fill: Option<Rgb>, stroke_width, wrap, f: Freeform| InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w: f.w,
|
||||
h: f.h,
|
||||
fill,
|
||||
fill: fill.map(Fill::solid),
|
||||
stroke: Some(Rgb(0xC0, 0, 0)),
|
||||
stroke_width,
|
||||
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() },
|
||||
@@ -1951,7 +1975,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
||||
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
|
||||
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let objs = ¶s(&r)[0].objects;
|
||||
assert_eq!(objs.len(), 3);
|
||||
// Written in reverse (each inserted at the start): the triangle, the highlighter, the pen.
|
||||
@@ -1975,7 +1999,7 @@ fn shape_effects_round_trip() {
|
||||
kind: ShapeKind::RoundedRectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(Rgb(0x15, 0x60, 0x82)),
|
||||
fill: Some(Fill::solid(Rgb(0x15, 0x60, 0x82))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
@@ -2051,7 +2075,7 @@ fn rotation_and_flips_round_trip() {
|
||||
kind,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2108,7 +2132,7 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { rot: 20.0, ..Default::default() },
|
||||
@@ -2306,7 +2330,7 @@ fn wrap_polygons_round_trip() {
|
||||
kind,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2332,7 +2356,7 @@ fn wrap_polygons_round_trip() {
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let r = unstacked(wordcraft_docx::read(&bytes).expect("read"));
|
||||
let got = paras(&r)[0];
|
||||
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
|
||||
assert_eq!(floats[0], edited);
|
||||
@@ -2430,7 +2454,7 @@ fn wrap_text_sides_round_trip() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2484,7 +2508,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { alt, ..Default::default() },
|
||||
|
||||
@@ -196,3 +196,41 @@ fn hidden_drawings_stay_hidden() {
|
||||
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
|
||||
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_in_box_keeps_its_frame() {
|
||||
use wordcraft_doc::{Paragraph, Pos, StoryRef};
|
||||
|
||||
let mut d = Document::from_text("Anchor");
|
||||
let body = TextBody { anchor: VAlign::Center, inset: Some([0; 4]), ..Default::default() };
|
||||
let outer = text_box(&mut d, "Label", body);
|
||||
let id = outer.text_box().unwrap();
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
let media = d.add_media(bytes.into_inner(), "png");
|
||||
let image = InlineObject::Image {
|
||||
media: media.clone(),
|
||||
w: 90.0,
|
||||
h: 36.0,
|
||||
alt: "Synthetic marker".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, image, &Default::default()).unwrap();
|
||||
d.parts.get_mut(&id).unwrap().blocks.push(para_block(p));
|
||||
d.insert_object(&Pos::body(0, 0), outer, &Default::default()).unwrap();
|
||||
|
||||
let read = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let ids = boxes(&read);
|
||||
assert_eq!(ids.len(), 1, "a nested picture must not replace its text box");
|
||||
let id = ids[0];
|
||||
assert_eq!(read.parts[&id].body, body);
|
||||
assert!(read.plain_text(StoryRef::Part(id)).starts_with("Label"));
|
||||
let p = read.para(StoryRef::Part(id), &wordcraft_doc::Path::top(1)).unwrap();
|
||||
assert!(matches!(&p.objects[0], InlineObject::Image { w, h, alt, .. }
|
||||
if *w == 90.0 && *h == 36.0 && alt == "Synthetic marker"));
|
||||
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
|
||||
}
|
||||
@@ -175,3 +175,71 @@ fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph's characters other than objects, each with the link it carries.
|
||||
fn linked_chars(p: &wordcraft_doc::Paragraph) -> Vec<(char, Option<String>)> {
|
||||
p.text.char_indices().filter(|(_, c)| *c != wordcraft_doc::para::OBJ).map(|(i, c)| (c, p.props_of_char(i).link.clone())).collect()
|
||||
}
|
||||
|
||||
/// #2174: Word for the web writes each table of contents entry as a `HYPERLINK` complex field
|
||||
/// around the heading's text, a tab and a `PAGEREF` field. The entry reads as linked text, as a
|
||||
/// `w:hyperlink` would: the tab is a tab (it reaches the dotted right tab stop) and the page
|
||||
/// number stays a field of its own, through a save.
|
||||
#[test]
|
||||
fn hyperlink_field_entries_read_as_linked_text_with_their_page_field() {
|
||||
let entry = |anchor: &str, text: &str, page: &str| {
|
||||
format!(
|
||||
r#"{}<w:r><w:t>{text}</w:t></w:r><w:r><w:tab/></w:r>{}<w:r><w:t>{page}</w:t></w:r>{END}{END}"#,
|
||||
start(&format!(r#"HYPERLINK \l "{anchor}" "#)),
|
||||
start(&format!(r#"PAGEREF {anchor} \h"#))
|
||||
)
|
||||
};
|
||||
let ppr = r#"<w:pPr><w:pStyle w:val="TOC1"/><w:tabs><w:tab w:val="right" w:leader="dot" w:pos="9015"/></w:tabs></w:pPr>"#;
|
||||
let body = format!(
|
||||
r#"<w:p>{ppr}{}{}</w:p><w:p>{ppr}{}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
|
||||
start(r#"TOC \o \z \u \h"#),
|
||||
entry("_Toc1", "Intro", "1"),
|
||||
entry("_Toc2", "Title 2", "2")
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
for d in [&doc, &saved] {
|
||||
for (i, anchor, text, page) in [(0, "_Toc1", "Intro", "1"), (1, "_Toc2", "Title 2", "2")] {
|
||||
let p = d.body[i].as_para().unwrap();
|
||||
assert_eq!(p.plain_text(), format!("{text}\t{page}"));
|
||||
let link = Some(format!("#{anchor}"));
|
||||
assert_eq!(linked_chars(p), format!("{text}\t").chars().map(|c| (c, link.clone())).collect::<Vec<_>>());
|
||||
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { instr, .. } if instr.starts_with("HYPERLINK"))));
|
||||
let pageref = format!(r#"PAGEREF {anchor} \h"#);
|
||||
assert!(p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if *instr == pageref && result == page)));
|
||||
}
|
||||
assert_eq!(d.field_ranges(StoryRef::Body).len(), 1);
|
||||
assert_eq!(d.body[2].as_para().unwrap().plain_text(), "After.");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `HYPERLINK` field's target, whatever the field's spelling: a web address with a bookmark,
|
||||
/// quoted words with spaces, a tooltip or target frame before it, and a simple field. One with
|
||||
/// no target stays a field.
|
||||
#[test]
|
||||
fn hyperlink_field_targets_follow_the_field_code() {
|
||||
let simple = |code: &str| format!(r#"<w:p><w:fldSimple w:instr="{code}"><w:r><w:t>Go</w:t></w:r></w:fldSimple></w:p>"#);
|
||||
let complex = |code: &str| format!(r#"<w:p>{}<w:r><w:t>Go</w:t></w:r>{END}</w:p>"#, start(code));
|
||||
for (para, link) in [
|
||||
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
|
||||
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
|
||||
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
|
||||
(simple(r#"HYPERLINK \l "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
(complex("HYPERLINK"), None),
|
||||
(complex(r#"HYPERLINKS "https://example.org/""#), None),
|
||||
] {
|
||||
let doc = wordcraft_docx::read(&package(¶)).unwrap();
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(p.plain_text(), "Go");
|
||||
match link {
|
||||
Some(l) => assert_eq!(linked_chars(p), "Go".chars().map(|c| (c, Some(l.to_string()))).collect::<Vec<_>>(), "{para}"),
|
||||
None => assert!(matches!(p.objects.as_slice(), [InlineObject::Field { result, .. }] if result == "Go"), "{para}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,8 +183,8 @@ fn a_pict_of_a_rect_a_text_box_and_a_group_all_come_through() {
|
||||
assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]);
|
||||
let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) };
|
||||
assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle], "the green rectangle is read");
|
||||
let InlineObject::Shape { fill, .. } = children[0].obj else { panic!() };
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)), "with its colour");
|
||||
let InlineObject::Shape { fill, .. } = children[0].obj.clone() else { panic!() };
|
||||
assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00))), "with its colour");
|
||||
// And it is still there after a save, which is what the round trip lost.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::<Vec<_>>(), [1, 1, 1]);
|
||||
@@ -255,9 +255,9 @@ fn a_group_member_finds_its_shapetype_beside_the_group() {
|
||||
)));
|
||||
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!(children.len(), 1, "the member is read");
|
||||
let InlineObject::Shape { kind, fill, .. } = children[0].obj else { panic!("{:?}", children[0].obj) };
|
||||
let InlineObject::Shape { kind, fill, .. } = children[0].obj.clone() else { panic!("{:?}", children[0].obj) };
|
||||
assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt");
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0x00, 0xff)), "and the fill from its fillcolor");
|
||||
assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0x00, 0xff))), "and the fill from its fillcolor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,6 +267,32 @@ fn a_top_level_shape_still_finds_its_shapetype() {
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#00ff00"/>"##,
|
||||
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
|
||||
)));
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00))));
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0].clone() else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_takes_its_gradient_from_the_shapetype() {
|
||||
// #1500: the shape states nothing but its shapetype; the shapetype's `v:fill` (a gradient to
|
||||
// `color2`, its opacity in 65536ths) is the shape's fill, and it survives saving.
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind};
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
let d = read_body(&pict(concat!(
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#ff0000"><v:fill type="gradient" color2="#0000ff" opacity="32768f"/></v:shapetype>"##,
|
||||
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
|
||||
)));
|
||||
let stop = |pos: f32, color, transparency: f32| GradStop { pos, color, transparency };
|
||||
let want = Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, wordcraft_doc::Rgb(0xff, 0, 0), 50.0), stop(100.0, wordcraft_doc::Rgb(0, 0, 0xff), 0.0)],
|
||||
kind: GradientKind::Linear { angle: 90.0, scaled: false },
|
||||
tile: None,
|
||||
rot_with_shape: true,
|
||||
flip: GradientFlip::None,
|
||||
}));
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(doc)[0].clone() else { panic!("{:?}", objects(doc)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(want.clone())));
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,14 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-automata = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32).
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32-unknown-unknown).
|
||||
js-sys = "0.3"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -30,6 +30,7 @@ File|Backstage|Version History|file.versions
|
||||
File|Backstage|Recover Unsaved|file.recover
|
||||
File|Backstage|New from Template|file.newFromTemplate
|
||||
File|Backstage|Save as Template|file.saveTemplate
|
||||
File|Backstage|Personal Templates|file.listTemplates
|
||||
Quick Access|Toolbar|Undo|edit.undo
|
||||
Quick Access|Toolbar|Undo List|edit.undoTo
|
||||
Quick Access|Toolbar|Redo|edit.redo
|
||||
@@ -56,6 +57,7 @@ Home|Font|Bold|format.bold
|
||||
Home|Font|Italic|format.italic
|
||||
Home|Font|Underline|format.underline
|
||||
Home|Font|Double Underline|format.doubleUnderline
|
||||
Home|Font|Underline Color|format.underlineColor
|
||||
Home|Font|Strikethrough|format.strikethrough
|
||||
Home|Font|Subscript|format.subscript
|
||||
Home|Font|Superscript|format.superscript
|
||||
@@ -455,6 +457,8 @@ Chart Design|Chart Layouts|Chart Title|chart.title
|
||||
Chart Design|Chart Layouts|Legend|chart.legend
|
||||
Chart Design|Chart Layouts|Data Labels|chart.dataLabels
|
||||
Shape Format|Shape Styles|Shape Fill|shape.fill
|
||||
Shape Format|Shape Styles|Shape Fill Gradient|shape.fill
|
||||
Shape Format|Shape Styles|Format Shape Pane|view.formatShapePane
|
||||
Shape Format|Shape Styles|Shape Outline|shape.outline
|
||||
Shape Format|Shape Styles|Shape Effects|shape.effects
|
||||
Shape Format|WordArt Styles|WordArt Quick Styles|wordArt.style
|
||||
@@ -474,6 +478,7 @@ Editing|Keyboard|New Paragraph|text.newParagraph
|
||||
Editing|Keyboard|Line Break|text.lineBreak
|
||||
Editing|Keyboard|Column Break|text.columnBreak
|
||||
Editing|Keyboard|Tab|text.tab
|
||||
Editing|Keyboard|Tab Character|text.insertTab
|
||||
Editing|Keyboard|Backspace|text.backspace
|
||||
Editing|Keyboard|Delete|text.delete
|
||||
Editing|Keyboard|Delete Previous Word|text.deleteWordBack
|
||||
@@ -515,6 +520,7 @@ Tools|Customize|Customize Ribbon|tools.customizeRibbon
|
||||
Tools|Customize|Customize Keyboard|tools.customizeKeyboard
|
||||
Tools|Protection|Encrypt with Password|file.encrypt
|
||||
Tools|Templates|Templates and Add-ins|tools.templates
|
||||
Tools|Views|Full Screen View|view.fullScreenView
|
||||
";
|
||||
|
||||
/// Catalog entries: (tab, group, feature, id).
|
||||
|
||||
@@ -58,7 +58,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE);
|
||||
let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE);
|
||||
let style = CanvasStyle {
|
||||
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse),
|
||||
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse).map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse),
|
||||
stroke_width: 0.75,
|
||||
};
|
||||
@@ -275,10 +275,14 @@ mod tests {
|
||||
// Fill and outline colour the canvas.
|
||||
s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap();
|
||||
s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap();
|
||||
let style = *canvas(&s).canvas_style().unwrap();
|
||||
let style = canvas(&s).canvas_style().unwrap().clone();
|
||||
assert_eq!(
|
||||
style,
|
||||
CanvasStyle { fill: wordcraft_doc::props::Rgb::parse("FFF2CC"), stroke: wordcraft_doc::props::Rgb::parse("1F4E79"), stroke_width: 1.5 }
|
||||
CanvasStyle {
|
||||
fill: wordcraft_doc::props::Rgb::parse("FFF2CC").map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke: wordcraft_doc::props::Rgb::parse("1F4E79"),
|
||||
stroke_width: 1.5
|
||||
}
|
||||
);
|
||||
// Members move, resize and go within the canvas.
|
||||
s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap();
|
||||
|
||||
@@ -378,8 +378,33 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
|
||||
select_unit(s, Unit::Sentence)
|
||||
}
|
||||
|
||||
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
select_unit(s, Unit::Paragraph)
|
||||
/// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word
|
||||
/// takes its end mark too, which shades it), so its borders then move on their own
|
||||
/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected.
|
||||
fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = select_unit(s, Unit::Paragraph);
|
||||
if v.get("cell").and_then(Value::as_bool) != Some(true) {
|
||||
return r;
|
||||
}
|
||||
let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone());
|
||||
let n = a.path.0.len();
|
||||
if a.story == b.story
|
||||
&& a.path.0.len() == b.path.0.len()
|
||||
&& let Some(tp) = a.path.0.get(..n.saturating_sub(3))
|
||||
&& !tp.is_empty()
|
||||
&& a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1))
|
||||
&& let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2)))
|
||||
{
|
||||
let tp = wordcraft_doc::Path(tp.to_vec());
|
||||
let (row, cell) = (row as usize, cell as usize);
|
||||
let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1));
|
||||
let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l));
|
||||
let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell));
|
||||
if let (true, Some(cols)) = (only, grid) {
|
||||
return super::table::select_range(s, a.story, &tp, (row, row), cols);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
|
||||
|
||||
@@ -8,6 +8,7 @@ use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
|
||||
|
||||
use super::references::FieldCode;
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -312,12 +313,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"title"?: string}"#),
|
||||
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", |s, v| {
|
||||
let label = p::str(v, "label").unwrap_or("Figure").to_string();
|
||||
generated_list(s, &format!("TOC \\c \"{label}\""), "")?;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"label"?: "Figure|Table|Equation"}"#),
|
||||
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", table_of_figures).params(
|
||||
r#"{"label"?: "Figure|Table|Equation" or a caption label of your own, "includeLabel"?: bool, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none"}"#,
|
||||
),
|
||||
CommandSpec::new("references.updateFigures", "Update Table", "References › Captions", |s, _| {
|
||||
update_generated(s)?;
|
||||
sel_result(s)
|
||||
@@ -771,9 +769,43 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
/// number; `\n` leaves page numbers out and `\p " "` puts them right after the text.
|
||||
pub fn figures_instr(v: &Value) -> String {
|
||||
let label: String = p::str(v, "label").unwrap_or("Figure").chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect();
|
||||
let ident = seq_ident(&label);
|
||||
let ident = if ident.is_empty() { "Figure".to_string() } else { ident };
|
||||
let on = |k: &str| p::bool(v, k).unwrap_or(true);
|
||||
let mut s = format!("TOC \\{} \"{ident}\"", if on("includeLabel") { 'c' } else { 'a' });
|
||||
if !on("pageNumbers") {
|
||||
s.push_str(" \\n");
|
||||
} else if !on("rightAlign") {
|
||||
s.push_str(" \\p \" \"");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The SEQ identifier of a caption label: Word joins a multi-word label with underscores.
|
||||
fn seq_ident(label: &str) -> String {
|
||||
label.split_whitespace().collect::<Vec<_>>().join("_")
|
||||
}
|
||||
|
||||
fn table_of_figures(s: &mut Session, v: &Value) -> CmdResult {
|
||||
generated_list_with(s, &figures_instr(v), "", p::str(v, "tabLeader").map(super::references::leader))?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding
|
||||
/// the field, then entry paragraphs marked with the "GeneratedEntry" style family.
|
||||
pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> {
|
||||
generated_list_with(s, instr, title, None)
|
||||
}
|
||||
|
||||
/// [`generated_list`] whose entries take `leader`. The leader lives in the entries' tab stops (as
|
||||
/// in a table of contents) and an update keeps the first entry's, so a placeholder entry carries
|
||||
/// it through the rebuild, however the lists above it change.
|
||||
fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option<TabLeader>) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if at.story != StoryRef::Body || at.path.depth() > 0 {
|
||||
return Err(CmdError::Failed("lists go in the main text".into()));
|
||||
@@ -787,6 +819,11 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul
|
||||
)?;
|
||||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
|
||||
if let Some(leader) = leader {
|
||||
let mut seed = Paragraph::new().styled(ENTRY_STYLE);
|
||||
seed.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Right, leader }]);
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i.saturating_add(1)), Block::Para(seed))?;
|
||||
}
|
||||
update_generated(s)
|
||||
}
|
||||
|
||||
@@ -794,12 +831,83 @@ fn list_kind(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let t = instr.trim_start();
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || t.starts_with("TOC \\c")).then(|| t.to_string())
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
|
||||
fn figures_label(instr: &str) -> Option<String> {
|
||||
let f = FieldCode::parse(instr);
|
||||
if !f.is_table_of_figures() {
|
||||
return None;
|
||||
}
|
||||
Some(f.arg('c').or_else(|| f.arg('a')).map_or_else(|| "Figure".to_string(), seq_ident))
|
||||
}
|
||||
|
||||
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
|
||||
/// caption typed by hand, without a SEQ field, goes by its label.
|
||||
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
|
||||
if p.props.style.as_deref() != Some("Caption") {
|
||||
return false;
|
||||
}
|
||||
let seq = p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => Some(FieldCode::parse(instr)).filter(|f| f.name == "SEQ"),
|
||||
_ => None,
|
||||
});
|
||||
match seq {
|
||||
Some(f) => f.args.first().is_some_and(|id| id.eq_ignore_ascii_case(ident)),
|
||||
None => caption_label(p, &p.plain_text()).is_some_and(|l| seq_ident(&l).eq_ignore_ascii_case(ident)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A caption's text without its label and number (`\a`): what follows its SEQ field, or for a
|
||||
/// caption typed by hand what follows the label and the number, less the separator.
|
||||
fn caption_body(p: &Paragraph, label: &str) -> String {
|
||||
let seq_end = p.object_offsets().into_iter().find_map(|o| match p.object_at(o) {
|
||||
Some(InlineObject::Field { instr, .. }) if FieldCode::parse(instr).name == "SEQ" => Some(o + wordcraft_doc::para::OBJ.len_utf8()),
|
||||
_ => None,
|
||||
});
|
||||
let rest = match seq_end {
|
||||
Some(end) => {
|
||||
let mut k = 0;
|
||||
let mut rest = String::new();
|
||||
for (at, c) in p.text.char_indices() {
|
||||
if c == wordcraft_doc::para::OBJ {
|
||||
if at >= end
|
||||
&& let Some(o) = p.objects.get(k)
|
||||
{
|
||||
rest.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
} else if at >= end {
|
||||
rest.push(c);
|
||||
}
|
||||
}
|
||||
rest
|
||||
}
|
||||
None => {
|
||||
let text = p.plain_text();
|
||||
// The label in any case, as captions are matched (ASCII case keeps the byte length).
|
||||
let start = text.trim_start();
|
||||
let after = start
|
||||
.get(..label.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(label))
|
||||
.and_then(|_| start.get(label.len()..))
|
||||
.unwrap_or(&text)
|
||||
.trim_start();
|
||||
// The number: the word after the label, when it starts with a digit.
|
||||
match after.find(char::is_whitespace) {
|
||||
Some(n) if after.starts_with(|c: char| c.is_ascii_digit()) => after[n..].to_string(),
|
||||
_ if after.starts_with(|c: char| c.is_ascii_digit()) => String::new(),
|
||||
_ => after.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
rest.trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '\u{2013}' | '\u{2014}')).trim_end().to_string()
|
||||
}
|
||||
|
||||
const ENTRY_STYLE: &str = "ListEntry";
|
||||
|
||||
/// Rebuild every generated list.
|
||||
@@ -817,7 +925,38 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Remove old entries.
|
||||
// A table of figures from Word can hold the list it saved as the field's result, in the
|
||||
// field's own paragraph: the rebuilt entries replace it, keeping its leader.
|
||||
let mut saved_leader = None;
|
||||
if figures_label(&kind).is_some()
|
||||
&& let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(i))
|
||||
{
|
||||
let leader = p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader);
|
||||
let mut cleared = false;
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o
|
||||
&& !result.is_empty()
|
||||
&& figures_label(instr).is_some()
|
||||
{
|
||||
result.clear();
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if cleared {
|
||||
saved_leader = leader;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
// Remove old entries, keeping their tab leader.
|
||||
let leader = s
|
||||
.doc
|
||||
.body
|
||||
.get(i + 1)
|
||||
.and_then(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
|
||||
.and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader))
|
||||
.or(saved_leader)
|
||||
.unwrap_or(TabLeader::Dot);
|
||||
while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
@@ -830,7 +969,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
para.props.indent_first = Some(-36.0);
|
||||
}
|
||||
if text.contains('\t') {
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]);
|
||||
}
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?;
|
||||
}
|
||||
@@ -874,12 +1013,14 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
}
|
||||
// Collect marks with positions.
|
||||
let mut marks: Vec<(String, Pos)> = Vec::new();
|
||||
let caption_label = kind.split('"').nth(1).unwrap_or("Figure").to_string();
|
||||
let figures = figures_label(kind);
|
||||
let bare = FieldCode::parse(kind).has('a');
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if kind.starts_with("TOC \\c") {
|
||||
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
|
||||
marks.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
|
||||
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -900,8 +1041,8 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if kind.starts_with("TOC \\c") {
|
||||
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
|
||||
}
|
||||
continue;
|
||||
@@ -923,8 +1064,11 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
x.1 = pg;
|
||||
}
|
||||
}
|
||||
if kind.starts_with("TOC \\c") {
|
||||
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
|
||||
if figures.is_some() {
|
||||
// `\n` without page numbers; `\p` the separator before them (a tab by default).
|
||||
let f = FieldCode::parse(kind);
|
||||
let sep = f.arg('p').unwrap_or("\t");
|
||||
return with_pages.into_iter().map(|(t, p)| (if f.has('n') { t } else { format!("{t}{sep}{p}") }, false)).collect();
|
||||
}
|
||||
// Index / TOA: group by entry, sort, list pages.
|
||||
let lang = super::default_lang(s);
|
||||
@@ -1297,6 +1441,190 @@ mod tests {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// The entries of every generated list, in order, with their last tab stop's leader.
|
||||
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
|
||||
.map(|p| (p.plain_text(), p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #3013: a table of figures for any caption label, so a document can have a list of figures
|
||||
/// and a list of tables; each keeps its own label and leader when updated.
|
||||
#[test]
|
||||
fn table_of_figures_for_each_caption_label() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\nTablet\n"));
|
||||
for (text, label) in [("Kiln", "Figure"), ("Press", "Table"), ("Rock", "Photo Plate"), ("Tablet", "Figure")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
|
||||
assert_eq!(list_entries(&s), [("Table 1: Press\t1".to_string(), Some(TabLeader::Hyphen))]);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Photo Plate"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({})).unwrap();
|
||||
let instrs: Vec<String> = fields(&s).into_iter().map(|(i, _)| i).filter(|i| i.starts_with("TOC")).collect();
|
||||
assert_eq!(instrs, [r#"TOC \c "Table""#, r#"TOC \c "Photo_Plate""#, r#"TOC \c "Figure""#]);
|
||||
let want = [
|
||||
("Table 1: Press\t1", TabLeader::Hyphen),
|
||||
("Photo Plate 1: Rock\t1", TabLeader::Dot),
|
||||
("Figure 1: Kiln\t1", TabLeader::Dot),
|
||||
("Figure 2: Tablet\t1", TabLeader::Dot),
|
||||
];
|
||||
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
// Update Table rebuilds each list with its own label and keeps the leader.
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
// A table of figures is not the table of contents.
|
||||
assert!(s.run("references.updateToc", &json!({})).is_ok());
|
||||
assert_eq!(list_entries(&s), want);
|
||||
}
|
||||
|
||||
/// #3013: Insert Table of Figures' options as TOC switches: `\a` without the label and number,
|
||||
/// `\n` without page numbers, `\p " "` with the page right after the text.
|
||||
#[test]
|
||||
fn table_of_figures_options() {
|
||||
assert_eq!(figures_instr(&json!({})), r#"TOC \c "Figure""#);
|
||||
assert_eq!(figures_instr(&json!({"label": "Table", "includeLabel": false})), r#"TOC \a "Table""#);
|
||||
assert_eq!(figures_instr(&json!({"pageNumbers": false})), r#"TOC \c "Figure" \n"#);
|
||||
assert_eq!(figures_instr(&json!({"rightAlign": false})), r#"TOC \c "Figure" \p " ""#);
|
||||
assert_eq!(figures_instr(&json!({"label": "\"\\ "})), r#"TOC \c "Figure""#, "an empty label is Figure");
|
||||
for (params, entry) in [
|
||||
(json!({"includeLabel": false}), "The kiln\t1"),
|
||||
(json!({"pageNumbers": false}), "Figure 1: The kiln"),
|
||||
(json!({"rightAlign": false, "includeLabel": false}), "The kiln 1"),
|
||||
] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\n"));
|
||||
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "The kiln"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", ¶ms).unwrap();
|
||||
let entries = list_entries(&s);
|
||||
assert_eq!(entries.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), [entry], "{params}");
|
||||
assert_eq!(entries.first().and_then(|(_, l)| *l).is_some(), entry.contains('\t'), "a tab stop only for a tab");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3013: a table of figures from Word (`TOC \h \z \c "Figure"`, switches in any order) and
|
||||
/// hand-typed captions are listed (the label in any case); a caption whose label only starts
|
||||
/// with another's is not.
|
||||
#[test]
|
||||
fn table_of_figures_from_word_and_typed_captions() {
|
||||
let mut d = wordcraft_doc::Document::from_text("Figure 1: Typed\nFigures 2: Not a figure\nFigure 3 \u{2014} Dashed\nfigure 4: Lower\nList");
|
||||
for path in d.para_paths(StoryRef::Body).into_iter().take(4) {
|
||||
d.para_mut(StoryRef::Body, &path).unwrap().props.style = Some("Caption".into());
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
let at = s.sel.focus.clone();
|
||||
let field = InlineObject::Field {
|
||||
instr: r#"TOC \h \z \a "Figure""#.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
s.doc.insert_object(&at, field, &CharProps::default()).unwrap();
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(entries, ["Typed\t1", "Dashed\t1", "Lower\t1"]);
|
||||
}
|
||||
|
||||
/// #3013 review: the leader asked for lands on the new list even when a list above it is out
|
||||
/// of date and gains entries (moving the new one down) while every list is rebuilt.
|
||||
#[test]
|
||||
fn table_of_figures_leader_survives_a_stale_list_above() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\n"));
|
||||
for (text, label) in [("Kiln", "Figure"), ("Press", "Table")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Rock"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "Rock"})).unwrap();
|
||||
assert_eq!(list_entries(&s).len(), 1, "the list of figures is out of date");
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
|
||||
let want = [("Figure 1: Kiln\t1", TabLeader::Dot), ("Figure 2: Rock\t1", TabLeader::Dot), ("Table 1: Press\t1", TabLeader::Hyphen)];
|
||||
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
}
|
||||
|
||||
/// #3013 review: a table of figures from Word whose saved list sits in the field's result (one
|
||||
/// paragraph) is replaced by the rebuilt entries, not listed twice, and keeps its leader.
|
||||
#[test]
|
||||
fn table_of_figures_from_word_replaces_its_saved_list() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nList"));
|
||||
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "Kiln"})).unwrap();
|
||||
let last = s.doc.body.len() - 1;
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(last)).unwrap();
|
||||
p.props.style = Some("TableofFigures".into());
|
||||
p.props.tabs = Some(vec![TabStop { pos: 400.0, align: TabAlign::Right, leader: TabLeader::Hyphen }]);
|
||||
let end = p.len();
|
||||
p.delete(0, end).unwrap();
|
||||
let field = InlineObject::Field {
|
||||
instr: r#"TOC \h \z \c "Figure""#.into(),
|
||||
result: "Figure 1: Kiln\t1".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
p.insert_object(0, field, &CharProps::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches("Figure 1: Kiln\t1").count(), 1, "{text:?}");
|
||||
assert_eq!(list_entries(&s), [("Figure 1: Kiln\t1".to_string(), Some(TabLeader::Hyphen))]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3013 review: `\c` picks captions by their SEQ identifier, as Word does, not by the text
|
||||
/// before the number (`STYLEREF`, " Specimen ", `SEQ Figure` is a figure).
|
||||
#[test]
|
||||
fn table_of_figures_goes_by_the_seq_identifier() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Rock\n"));
|
||||
let field = |instr: &str| InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let plain = CharProps::default();
|
||||
let mut imported = Paragraph::new().styled("Caption");
|
||||
imported.insert_object(0, field("STYLEREF 1 \\s"), &plain).unwrap();
|
||||
imported.insert_text(imported.len(), " Specimen ", &plain).unwrap();
|
||||
imported.insert_object(imported.len(), field("SEQ Figure \\* ARABIC"), &plain).unwrap();
|
||||
imported.insert_text(imported.len(), ": A rock", &plain).unwrap();
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(1), Block::Para(imported)).unwrap();
|
||||
for (label, listed) in [("Figure", true), ("Specimen", false)] {
|
||||
let mut s = Session::new(s.doc.clone());
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": label, "includeLabel": false})).unwrap();
|
||||
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(entries.len(), usize::from(listed), "{label}: {entries:?}");
|
||||
if listed {
|
||||
assert_eq!(entries.first().map(|t| t.split('\t').next()), Some(Some("A rock")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2940: a caption may start with its chapter number (`STYLEREF`) and carry its label text
|
||||
/// between that and the `SEQ` number; the text is the label, not the `SEQ` identifier.
|
||||
#[test]
|
||||
|
||||
@@ -166,6 +166,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
{
|
||||
return r;
|
||||
}
|
||||
// Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each,
|
||||
// as in Word (#2802).
|
||||
if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward")
|
||||
&& let Some(r) = super::table::clear_cells(s)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -123,23 +123,33 @@ pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
|
||||
pub fn days_in_month(y: i64, m: u32) -> u32 {
|
||||
match m {
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono, for any `y`. An invalid month keeps the old 31-day default.
|
||||
let y = y.rem_euclid(400);
|
||||
let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1));
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(31),
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]).
|
||||
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]), exact for any year.
|
||||
///
|
||||
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year
|
||||
/// within its cycle and whole cycles are added back as days (an i128 so the extremes can't
|
||||
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163; the result matches the proleptic
|
||||
/// Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = i64::from((m + 9) % 12);
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The built-in name of the placeholder style. Its id is localized: German Word writes
|
||||
@@ -750,6 +760,22 @@ mod tests {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
|
||||
// pre-chrono formula, both directions, both sides of the epoch.
|
||||
for (y, m, d, days) in
|
||||
[(262_144i64, 1u32, 1u32, 95_026_602i64), (273_022, 10, 18, 99_000_000), (-262_145, 3, 1, -96_465_964), (-271_821, 4, 21, -99_999_999)]
|
||||
{
|
||||
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
|
||||
assert_eq!(super::super::civil_from_days(days), (y, m, d), "{days}");
|
||||
}
|
||||
assert_eq!(days_in_month(272_952, 2), 29, "272952 is a leap year");
|
||||
assert_eq!(days_in_month(-269_153, 2), 28);
|
||||
// The extremes must not overflow or panic (i128 intermediates / `rem_euclid`).
|
||||
let _ = (days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1), days_in_month(i64::MAX, 2), days_in_month(i64::MIN, 2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -670,7 +670,17 @@ fn step(s: &mut Session, dir: i64) -> CmdResult {
|
||||
Ok(json!({"index": idx, "count": n}))
|
||||
}
|
||||
|
||||
/// Replace needs something to look for: the `text` given, or the last search's (Find then Replace
|
||||
/// with just a `with`). Neither is a call that would silently replace nothing (a misspelt `text`).
|
||||
fn need_query(s: &Session, v: &Value) -> Result<(), CmdError> {
|
||||
if p::str(v, "text").is_none() && s.find.query.is_empty() {
|
||||
return Err(CmdError::Params("`text` (string) is required".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
need_query(s, v)?;
|
||||
read_opts(s, v);
|
||||
let story = s.sel.focus.story;
|
||||
let results = search_full(s, story, true)?;
|
||||
@@ -686,6 +696,7 @@ fn replace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in
|
||||
/// the replacement (`^p`) splits the paragraph.
|
||||
fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
|
||||
need_query(s, v)?;
|
||||
read_opts(s, v);
|
||||
let story = s.sel.focus.story;
|
||||
let results = search_full(s, story, true)?;
|
||||
|
||||
@@ -193,6 +193,7 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
|
||||
.map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32)
|
||||
.unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX));
|
||||
let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default();
|
||||
let (fn_name, fn_path) = s.resolved_filenames();
|
||||
let ctx = wordcraft_layout::fields::FieldCtx {
|
||||
page: page.map(|p| p.number).unwrap_or(1),
|
||||
page_format,
|
||||
@@ -201,7 +202,8 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
|
||||
section,
|
||||
title: s.doc.core.title.as_str().into(),
|
||||
author: s.doc.core.creator.as_str().into(),
|
||||
filename: s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default().into(),
|
||||
filename: fn_name.into(),
|
||||
filepath: fn_path.into(),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_layout::fields::field_text(&instr, &result, &ctx).0
|
||||
@@ -283,6 +285,52 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// #1798: Update Field evaluates `DOCPROPERTY` against custom and built-in document
|
||||
/// properties (with a `\* MERGEFORMAT` switch), instead of leaving the cached result.
|
||||
#[test]
|
||||
fn update_fields_evaluates_docproperty() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.properties", &json!({"custom": {"ProjectName": "Atlas"}})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY \"ProjectName\" \\* MERGEFORMAT", "result": "stale"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
s.doc.core.title = "Report".into();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY Title", "result": ""})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let results: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.first()
|
||||
.and_then(|b| b.as_para())
|
||||
.map(|p| {
|
||||
p.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(results, ["Atlas", "Report"]);
|
||||
}
|
||||
|
||||
/// `DOCPROPERTY LastSavedBy`, Word's name for the last author, reads the last-modified-by
|
||||
/// property.
|
||||
#[test]
|
||||
fn update_fields_evaluates_docproperty_last_saved_by() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.doc.core.last_modified_by = "Ada".into();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY LastSavedBy", "result": "stale"})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let result = s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
assert_eq!(result.as_deref(), Some("Ada"));
|
||||
}
|
||||
|
||||
fn page_text(s: &mut Session) -> String {
|
||||
let l = s.layout();
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -45,8 +45,16 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(path) = &s.path {
|
||||
let mut destination = path.with_file_name(name);
|
||||
// The field edits the stem; retain the original format, including macro/template files.
|
||||
// A name that already ends in that extension is taken as the whole file name, since a
|
||||
// script or an agent passes one ("renamed.docx" became "renamed.docx.docx", #2983). The
|
||||
// extension alone (".docx") is a stem, not a file name, so it still gets one.
|
||||
if let Some(ext) = path.extension() {
|
||||
destination.set_file_name(format!("{name}.{}", ext.to_string_lossy()));
|
||||
let ext = ext.to_string_lossy();
|
||||
// Split at the name's own last dot rather than counting bytes back from its end: a
|
||||
// name whose characters are more than one byte each ("résumé.docm") would be cut
|
||||
// inside one of them, and slicing there panics (#3128 review).
|
||||
let given = name.rsplit_once('.').is_some_and(|(stem, suffix)| !stem.is_empty() && suffix.eq_ignore_ascii_case(ext.as_ref()));
|
||||
destination.set_file_name(if given { name.to_string() } else { format!("{name}.{ext}") });
|
||||
}
|
||||
if destination != *path {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -54,16 +62,18 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
|
||||
}
|
||||
s.path = Some(destination);
|
||||
s.relayout();
|
||||
}
|
||||
} else {
|
||||
s.file_name = Some(name.to_string());
|
||||
s.relayout();
|
||||
}
|
||||
Ok(json!({"name": name, "path": s.path}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(),
|
||||
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
|
||||
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open)
|
||||
.key("Mod+O / Mod+F12 / Mod+Alt+F2")
|
||||
@@ -182,6 +192,7 @@ fn privacy(s: &Session) -> Value {
|
||||
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
|
||||
}
|
||||
|
||||
/// Create a blank document in the saved proofing language, or use the requested template.
|
||||
fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let doc = match p::str(v, "template").unwrap_or("blank") {
|
||||
"sample" => crate::sample::sample_document(),
|
||||
@@ -199,12 +210,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// language (the templates' text is English).
|
||||
fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = &s.editing_lang {
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Open a document, using the saved proofing default only for plain text and Markdown.
|
||||
fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.ui_requests.push(json!({"open": "openFile"}));
|
||||
@@ -227,7 +239,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = &s.editing_lang
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
@@ -297,15 +309,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
|
||||
}
|
||||
}
|
||||
// Undone below if the write fails: a failed save (no permission, disk full, a bad path)
|
||||
// must not leave the document's own save stamps — zoom, modified/created, author, revision —
|
||||
// changed, or a later save would persist them for a write that never actually happened.
|
||||
let before_stamp =
|
||||
(s.doc.settings.zoom, s.doc.core.last_modified_by.clone(), s.doc.core.created.clone(), s.doc.core.modified.clone(), s.doc.core.revision);
|
||||
s.stamp_save();
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
// A header/footer just made and left empty isn't saved (#960).
|
||||
let copy = super::insert::saved_copy(s);
|
||||
crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts).map_err(CmdError::Failed)?;
|
||||
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
|
||||
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
|
||||
return Err(CmdError::Failed(e));
|
||||
}
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
|
||||
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
|
||||
let own_file = s.path.as_deref() == Some(path.as_path());
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
s.relayout();
|
||||
}
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ fn code_char(n: u32) -> Option<char> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A Windows-1252 code as its Unicode scalar: 128–159 hold typographic characters there (151 is
|
||||
/// an em dash); the five undefined slots and every other code stay as they are.
|
||||
fn ansi(n: u32) -> u32 {
|
||||
const HIGH: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
n.checked_sub(128).and_then(|k| HIGH.get(k as usize)).copied().unwrap_or(n)
|
||||
}
|
||||
|
||||
/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the
|
||||
/// character and how many characters it took.
|
||||
fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
|
||||
@@ -115,6 +125,8 @@ fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
|
||||
return None;
|
||||
}
|
||||
let n: u32 = digits.parse().ok()?;
|
||||
// `^0nnn` is a Windows-1252 (ANSI) code; `^nnn` and `^unnnn` are Unicode.
|
||||
let n = if start == i && digits.starts_with('0') { ansi(n) } else { n };
|
||||
Some((code_char(n)?, start - i + digits.chars().count()))
|
||||
}
|
||||
|
||||
@@ -415,3 +427,40 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn finds(query: &str, hay: &str) -> bool {
|
||||
let c = compile(query, &Opts::default()).unwrap();
|
||||
regex::Regex::new(&c.pattern).unwrap().is_match(hay)
|
||||
}
|
||||
|
||||
fn replaced(with: &str) -> String {
|
||||
expand(&parse_replace(with, &Opts::default()), "", &|_| None, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_codes_128_to_159_are_windows_1252() {
|
||||
for (code, ch) in [("^0151", '\u{2014}'), ("^0150", '\u{2013}'), ("^0128", '\u{20AC}'), ("^0145", '\u{2018}')] {
|
||||
let hay = format!("a{ch}b");
|
||||
assert!(finds(code, &hay), "{code} should find {ch:?}");
|
||||
assert!(!finds(code, code), "{code} should not match its literal text");
|
||||
let w = Opts { wildcards: true, ..Opts::default() };
|
||||
let c = compile(code, &w).unwrap();
|
||||
assert!(regex::Regex::new(&c.pattern).unwrap().is_match(&hay), "wildcard {code} should find {ch:?}");
|
||||
}
|
||||
assert_eq!(replaced("x^0151y"), "x\u{2014}y");
|
||||
assert_eq!(replaced("^0128"), "\u{20AC}");
|
||||
// Unchanged: other codes and the undefined Windows-1252 slots.
|
||||
assert!(finds("^13", "a\rb"));
|
||||
assert!(finds("^0065", "A"));
|
||||
for undefined in ["^0129", "^0141", "^0143", "^0144", "^0157"] {
|
||||
assert_eq!(replaced(undefined), undefined);
|
||||
}
|
||||
// Without the leading zero the code is not read as Windows-1252.
|
||||
assert!(!finds("^151", "\u{2014}"));
|
||||
assert!(!finds("^150", "\u{2013}"));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::VMerge;
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
@@ -146,15 +147,30 @@ impl Recorder<'_> {
|
||||
(Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => {
|
||||
if let Block::Para(pa) = Arc::make_mut(a) {
|
||||
self.para(pb, pa);
|
||||
// The section's properties on the paragraph (`w:sectPrChange`).
|
||||
let (Some(b), Some(x)) = (pb.section.as_deref(), pa.section.as_deref_mut()) else { continue };
|
||||
if let Some(w) = self.change(b, x, strip_section, |s| &s.fmt_change) {
|
||||
x.fmt_change = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() => {
|
||||
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() && tb.grid == ta.grid => {
|
||||
let Block::Table(ta) = Arc::make_mut(a) else { continue };
|
||||
if let Some(w) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
|
||||
ta.props.fmt_change = w;
|
||||
}
|
||||
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
|
||||
if let Some(w) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
|
||||
ra.props.fmt_change = w;
|
||||
}
|
||||
if rb.cells.len() != ra.cells.len() {
|
||||
continue;
|
||||
}
|
||||
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
|
||||
// Merges are structure, not formatting: span and vertical merge stay out.
|
||||
if let Some(w) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
|
||||
ca.props.fmt_change = w;
|
||||
}
|
||||
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +181,21 @@ impl Recorder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatting-comparison strips: what isn't formatting in these properties. A cell's merge
|
||||
/// is structure, a section's headers and footers are content.
|
||||
fn strip_table(t: &TableProps) -> TableProps {
|
||||
TableProps { fmt_change: None, ..t.clone() }
|
||||
}
|
||||
fn strip_row(r: &RowProps) -> RowProps {
|
||||
RowProps { fmt_change: None, ..r.clone() }
|
||||
}
|
||||
fn strip_cell(c: &CellProps) -> CellProps {
|
||||
CellProps { fmt_change: None, span: 0, vmerge: VMerge::None, ..c.clone() }
|
||||
}
|
||||
fn strip_section(s: &SectionProps) -> SectionProps {
|
||||
SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() }
|
||||
}
|
||||
|
||||
/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as
|
||||
/// tracked formatting changes, when Track Changes and Track Formatting are on.
|
||||
pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
|
||||
@@ -102,6 +102,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
|
||||
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
|
||||
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
|
||||
CommandSpec::new("format.underlineColor", "Underline Color", "Home › Font › Underline", underline_color)
|
||||
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
|
||||
.params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
@@ -366,6 +368,17 @@ fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.color = Some(tc))
|
||||
}
|
||||
|
||||
/// `format.underlineColor`: the underline's own colour (`w:u w:color`), or `auto` to drop it so the
|
||||
/// underline follows the text colour again. The underline style is left as it is.
|
||||
fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "color")?;
|
||||
let rgb = match TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))? {
|
||||
TextColor::Auto => None,
|
||||
TextColor::Rgb(rgb) => Some(rgb),
|
||||
};
|
||||
apply(s, &|x| x.underline_color = rgb)
|
||||
}
|
||||
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::str(v, "color").unwrap_or("yellow");
|
||||
let h = Highlight::ALL
|
||||
@@ -603,7 +616,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
if matches!(c, '.' | '!' | '?') {
|
||||
let is_decimal = c == '.'
|
||||
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
|
||||
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
|
||||
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
|
||||
up = true;
|
||||
}
|
||||
n
|
||||
@@ -677,6 +693,7 @@ pub fn state(s: &Session) -> Value {
|
||||
"bold": all.iter().all(|r| r.bold),
|
||||
"italic": all.iter().all(|r| r.italic),
|
||||
"underline": all.iter().all(|r| r.underline != Underline::None),
|
||||
"underlineColor": same(&|r| json!(r.underline_color.map_or_else(|| "auto".to_string(), |c| c.hex()))),
|
||||
"strike": all.iter().all(|r| r.strike),
|
||||
"border": all.iter().all(|r| r.border.is_some()),
|
||||
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
|
||||
@@ -710,6 +727,13 @@ mod tests {
|
||||
assert_eq!(convert("hello world", "title", false), "Hello World");
|
||||
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_ignores_decimal_points() {
|
||||
// #3485: A period between two digits is a decimal point, not a sentence boundary.
|
||||
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
|
||||
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
|
||||
}
|
||||
}
|
||||
|
||||
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
|
||||
|
||||
@@ -65,11 +65,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false))
|
||||
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#),
|
||||
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
|
||||
// Back where the caret was in the body, as Word does, not on page 1.
|
||||
let back = s.hf_return.take().filter(|_| s.in_header_footer());
|
||||
if !new_empty_headers(s).is_empty() {
|
||||
// Its own undo step; a protected document keeps it, and the caret still leaves.
|
||||
let _ = s.run("insert.removeEmptyHeaders", &json!({}));
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
s.sel = back.unwrap_or_else(|| Selection::caret(s.doc.start_of(StoryRef::Body)));
|
||||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
@@ -340,8 +342,19 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Float { wrap: Wrap::Inline, ..Default::default() }
|
||||
};
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float, story: None, freeform: None, effects: Default::default(), extra };
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill: fill.map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
};
|
||||
// A selected Drawing Canvas takes it in (and stays selected for the next one).
|
||||
if super::canvas::add_member(s, obj.clone())?.is_some() {
|
||||
return sel_result(s);
|
||||
@@ -365,7 +378,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: Some(Rgb::WHITE),
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
@@ -456,9 +469,11 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit.
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
!name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40
|
||||
}
|
||||
|
||||
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
|
||||
@@ -842,18 +857,19 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
n += 1;
|
||||
}
|
||||
match (c, n) {
|
||||
('y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('M', 4..) => out.push_str(month),
|
||||
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
|
||||
('M', 2) => out.push_str(&format!("{m:02}")),
|
||||
('M', 1) => out.push_str(&m.to_string()),
|
||||
('d', 4..) => out.push_str(day),
|
||||
('d', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d', 1) => out.push_str(&d.to_string()),
|
||||
('d' | 'D', 4..) => out.push_str(day),
|
||||
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d' | 'D', 1) => out.push_str(&d.to_string()),
|
||||
('H', 2..) => out.push_str(&format!("{hh:02}")),
|
||||
('H', _) => out.push_str(&hh.to_string()),
|
||||
('h', 2..) => out.push_str(&format!("{:02}", if hh.is_multiple_of(12) { 12 } else { hh % 12 })),
|
||||
('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()),
|
||||
('m', 2..) => out.push_str(&format!("{mm:02}")),
|
||||
('m', _) => out.push_str(&mm.to_string()),
|
||||
@@ -967,6 +983,28 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
|
||||
/// body, not on page 1 (#3331).
|
||||
#[test]
|
||||
fn close_header_returns_to_the_body_selection() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "One"})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Two words here"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "words"})).unwrap();
|
||||
let before = s.sel.clone();
|
||||
// A header written to stays.
|
||||
s.run("insert.editHeader", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Head"})).unwrap();
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, before);
|
||||
// An empty one made by a double-click on page 2 is taken away again.
|
||||
s.run("insert.editFooter", &json!({"page": 1})).unwrap();
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, before);
|
||||
assert_eq!(s.doc.last_section.footers, Default::default());
|
||||
}
|
||||
|
||||
/// #960: editing the header of a page that has none creates the one that page shows (first
|
||||
/// page, even page or default); typing goes into it, and closing an unwritten one takes it
|
||||
/// away again so the saved file stays as it was.
|
||||
@@ -1173,6 +1211,47 @@ mod tests {
|
||||
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta"));
|
||||
}
|
||||
|
||||
/// #3491: Bookmark names are validated by Unicode character count and character classes.
|
||||
#[test]
|
||||
fn bookmark_name_validation_rules() {
|
||||
// Valid names:
|
||||
assert!(bookmark_name_ok("Mark1"));
|
||||
assert!(bookmark_name_ok("A"));
|
||||
assert!(bookmark_name_ok("Alpha_Beta_123"));
|
||||
// Valid leading underscore (Word hidden bookmarks, e.g. _Toc, _Ref, _Hidden):
|
||||
assert!(bookmark_name_ok("_Hidden"));
|
||||
assert!(bookmark_name_ok("_foo"));
|
||||
// Valid 27-character Unicode Cyrillic name (51 UTF-8 bytes):
|
||||
assert!(bookmark_name_ok("быстрый_доступ_к_документам"));
|
||||
// Valid 40-character name:
|
||||
let exactly_40 = "a".repeat(40);
|
||||
assert!(bookmark_name_ok(&exactly_40));
|
||||
let exactly_40_unicode = "я".repeat(40);
|
||||
assert!(bookmark_name_ok(&exactly_40_unicode));
|
||||
|
||||
// Invalid: empty
|
||||
assert!(!bookmark_name_ok(""));
|
||||
// Invalid: leading digit
|
||||
assert!(!bookmark_name_ok("1mark"));
|
||||
// Invalid: symbols / punctuation
|
||||
assert!(!bookmark_name_ok("a&b"));
|
||||
assert!(!bookmark_name_ok("a!b"));
|
||||
assert!(!bookmark_name_ok("a-b"));
|
||||
assert!(!bookmark_name_ok("a.b"));
|
||||
assert!(!bookmark_name_ok("foo bar"));
|
||||
// Invalid: more than 40 characters
|
||||
assert!(!bookmark_name_ok(&"a".repeat(41)));
|
||||
assert!(!bookmark_name_ok(&"я".repeat(41)));
|
||||
|
||||
// Verification through `insert.bookmark` command
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Test content"));
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "быстрый_доступ_к_документам"})).is_ok());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "_Hidden"})).is_ok());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "1mark"})).is_err());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "a&b"})).is_err());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "a!b"})).is_err());
|
||||
}
|
||||
|
||||
/// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after.
|
||||
#[test]
|
||||
fn a_mixed_symbol_string_keeps_symbols_and_text_apart() {
|
||||
@@ -1210,6 +1289,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn date_pictures() {
|
||||
// Word accepts either case for the day and year instructions.
|
||||
assert_eq!(format_date_parts("MM/DD/YYYY", 2026, 10, 21, (17, 27, 3), 0), "10/21/2026");
|
||||
assert_eq!(format_date_parts("D MMMM YYYY", 2026, 10, 21, (17, 27, 3), 0), "21 October 2026");
|
||||
// Both 12-hour instructions pad a single-digit hour; only the 12-hour one wraps at noon.
|
||||
assert_eq!(format_date_parts("hh:mm", 2026, 10, 21, (17, 27, 3), 0), "05:27");
|
||||
assert_eq!(format_date_parts("h:mm", 2026, 10, 21, (17, 27, 3), 0), "5:27");
|
||||
assert_eq!(format_date_parts("HH:mm", 2026, 10, 21, (5, 7, 3), 0), "05:07");
|
||||
let d = format_date("yyyy-MM-dd");
|
||||
assert_eq!(d.len(), 10);
|
||||
assert!(format_date("MMMM d, yyyy").contains(", "));
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod phonetic;
|
||||
pub mod protect;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod shape_fill;
|
||||
pub mod smart_art;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
@@ -46,6 +47,7 @@ pub mod table_style;
|
||||
pub mod templates;
|
||||
pub mod text;
|
||||
pub mod tools;
|
||||
pub mod user_templates;
|
||||
pub mod view;
|
||||
pub mod wordart;
|
||||
|
||||
@@ -89,6 +91,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(citations::specs());
|
||||
v.extend(index::specs());
|
||||
v.extend(objects::specs());
|
||||
v.extend(shape_fill::specs());
|
||||
v.extend(smart_art::specs());
|
||||
v.extend(chart::specs());
|
||||
v.extend(draw::specs());
|
||||
@@ -97,6 +100,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(connectors::specs());
|
||||
v.extend(tools::specs());
|
||||
v.extend(templates::specs());
|
||||
v.extend(user_templates::specs());
|
||||
v.extend(protect::specs());
|
||||
v.extend(speech::specs());
|
||||
v.extend(controls::specs());
|
||||
@@ -420,19 +424,27 @@ fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError
|
||||
|
||||
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
|
||||
pub fn iso_from_unix_secs(secs: u64) -> String {
|
||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
||||
use chrono::Datelike;
|
||||
let t = secs % 86_400;
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
|
||||
let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60);
|
||||
// Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer
|
||||
// `civil_from_days` gives rather than falling back to the epoch.
|
||||
let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) {
|
||||
Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()),
|
||||
None => civil_from_days((secs / 86_400) as i64),
|
||||
};
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{m:02}-{d:02}T{hms}Z")
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
|
||||
pub fn now_unix() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
{
|
||||
let ms = js_sys::Date::now();
|
||||
// Clamp a NaN or pre-epoch clock to 0 rather than fail.
|
||||
@@ -445,18 +457,25 @@ pub fn now_iso() -> String {
|
||||
iso_from_unix_secs(now_unix())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
/// Days since 1970-01-01 → (year, month, day).
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
|
||||
/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every
|
||||
/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day
|
||||
/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't
|
||||
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always
|
||||
/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
|
||||
@@ -484,7 +503,7 @@ pub fn default_lang(s: &Session) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod iso_tests {
|
||||
use super::iso_from_unix_secs;
|
||||
use super::{civil_from_days, iso_from_unix_secs};
|
||||
|
||||
#[test]
|
||||
fn formats_epoch_and_known_timestamps() {
|
||||
@@ -498,4 +517,26 @@ mod iso_tests {
|
||||
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_dates_outside_chronos_range() {
|
||||
// Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`.
|
||||
assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||
// Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch.
|
||||
assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_is_exact_beyond_chronos_range() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the
|
||||
// pre-chrono formula, both directions, both sides of the epoch.
|
||||
assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18));
|
||||
assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17));
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ fn has_group(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border.
|
||||
fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
|
||||
pub(crate) fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, o)) if o.is_canvas() => None,
|
||||
_ => has_shape(s),
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.when(has_sizable),
|
||||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
|
||||
.params(
|
||||
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.45; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
|
||||
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
|
||||
)
|
||||
.when(has_picture),
|
||||
// Kept for scripts written before every object had alt text: `object.altText` on a picture.
|
||||
@@ -324,10 +324,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} (Ctrl+drag: a copy of the selected object lands with its top-left there and is selected; the original and the clipboard stay as they were)"#)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
@@ -357,24 +357,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"index"?: n}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { fill, .. } = o {
|
||||
*fill = c;
|
||||
} else if let Some(style) = super::canvas::style_mut(o) {
|
||||
style.fill = c;
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null} (a selected drawing canvas: its background)"#)
|
||||
.when(has_shape_or_canvas),
|
||||
CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
let w = p::f32(v, "width").map(|w| w.clamp(0.0, 72.0));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { stroke, stroke_width, .. } = o {
|
||||
if let InlineObject::Shape { stroke, stroke_width, extra, .. } = o {
|
||||
*stroke = c;
|
||||
// A colour chosen here replaces an outline gradient from a file.
|
||||
extra.outline_fill = None;
|
||||
if let Some(w) = w {
|
||||
*stroke_width = w;
|
||||
}
|
||||
@@ -678,6 +668,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, None) => None,
|
||||
_ => return Err(CmdError::Params("`x` and `y` go together".into())),
|
||||
};
|
||||
let in_text = text_target(v, &obj)?;
|
||||
if in_text.is_some() && to.is_some() {
|
||||
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
|
||||
}
|
||||
let (w0, h0) = obj_size(&obj);
|
||||
let min = min_size(&obj);
|
||||
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
|
||||
@@ -685,24 +679,92 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if (w, h) != (w0, h0) {
|
||||
with_obj(s, |o| o.set_size(w, h))?;
|
||||
}
|
||||
let pos = match to {
|
||||
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
None => pos,
|
||||
let pos = match (to, in_text) {
|
||||
(Some((x, y)), _) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
(None, Some(at)) => move_in_text(s, pos, &at)?,
|
||||
(None, None) => pos,
|
||||
};
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
|
||||
select_object(s, &pos);
|
||||
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
|
||||
}
|
||||
|
||||
/// Select the object whose character is at `pos`.
|
||||
fn select_object(s: &mut Session, pos: &Pos) {
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
|
||||
}
|
||||
|
||||
/// The text position in `v`'s `to`, for an In Line with Text `obj` (a floating one is placed by
|
||||
/// `x`/`y`); None without one.
|
||||
fn text_target(v: &Value, obj: &InlineObject) -> Result<Option<Pos>, CmdError> {
|
||||
let Some(to) = v.get("to").filter(|t| !t.is_null()) else { return Ok(None) };
|
||||
if obj.is_floating() {
|
||||
return Err(CmdError::Params("`to` places an In Line with Text object; move a floating one by `x` and `y`".into()));
|
||||
}
|
||||
super::parse_pos(to).map(Some).ok_or_else(|| CmdError::Params("bad `to`".into()))
|
||||
}
|
||||
|
||||
/// The text position `to` an In Line with Text object at `pos` can land at: in the object's own
|
||||
/// story (a table cell there too), not inside a field's code or such (it's clamped to a
|
||||
/// character).
|
||||
fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||||
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
|
||||
}
|
||||
if to.story != pos.story || s.doc.para_at(to).is_none() {
|
||||
return Err(CmdError::Params("`to` must be a position in the object's own text".into()));
|
||||
}
|
||||
Ok(s.doc.clamp(to))
|
||||
}
|
||||
|
||||
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
|
||||
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
|
||||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// Returns the object's position afterwards.
|
||||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
let to = landing(s, &pos, to)?;
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let same_para = to.path == pos.path;
|
||||
if same_para && (to.off == pos.off || to.off == pos.off + len) {
|
||||
return Ok(pos);
|
||||
}
|
||||
let para = s.doc.para_at(&pos).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let obj = para.object_at(pos.off).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let props = para.props_of_char(pos.off).clone();
|
||||
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
let mut at = to;
|
||||
if same_para && at.off > pos.off {
|
||||
at.off = at.off.saturating_sub(len);
|
||||
}
|
||||
let at = s.doc.clamp(&at);
|
||||
s.doc.insert_object(&at, obj, &props)?;
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to
|
||||
/// (x, y), leaving the original in place and the clipboard alone. The copy is selected.
|
||||
/// (x, y), or for an In Line with Text object put it at the text position `to`, leaving the
|
||||
/// original in place and the clipboard alone. The copy is selected.
|
||||
fn copy_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let end = Pos { off: pos.off + len, ..pos.clone() };
|
||||
if let Some(to) = text_target(v, &obj)? {
|
||||
if p::f32(v, "x").is_some() || p::f32(v, "y").is_some() {
|
||||
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
|
||||
}
|
||||
let at = landing(s, &pos, &to)?;
|
||||
let frag = s.doc.copy_range(&pos, &end);
|
||||
s.sel = Selection::caret(at.clone());
|
||||
super::paste::insert(s, frag)?;
|
||||
// The copy starts where it was pasted.
|
||||
let copy = s.doc.clamp(&at);
|
||||
select_object(s, ©);
|
||||
let o = s.doc.para_at(©).and_then(|p| p.object_at(copy.off)).cloned();
|
||||
return Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(©)}));
|
||||
}
|
||||
if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() {
|
||||
return Err(CmdError::Params("`x` and `y` are required".into()));
|
||||
}
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let end = Pos { off: pos.off + len, ..pos.clone() };
|
||||
let frag = s.doc.copy_range(&pos, &end);
|
||||
s.sel = Selection::caret(end.clone());
|
||||
super::paste::insert(s, frag)?;
|
||||
@@ -775,27 +837,27 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
s.doc.insert_object(&at, obj, &wordcraft_doc::CharProps::default())?;
|
||||
at
|
||||
};
|
||||
let (h_rel, v_rel) = if on_page { (Anchor::Page, Anchor::Page) } else { (Anchor::Column, Anchor::Paragraph) };
|
||||
// First on the page where it was dropped, so the text wraps around it as it will in the end.
|
||||
edit_float(s, &at, |f| {
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.wrap = Wrap::Square;
|
||||
}
|
||||
f.h_rel = h_rel;
|
||||
f.v_rel = v_rel;
|
||||
f.h_rel = Anchor::Page;
|
||||
f.v_rel = Anchor::Page;
|
||||
f.h_align = None;
|
||||
f.v_align = None;
|
||||
f.x = 0.0;
|
||||
f.y = 0.0;
|
||||
f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
})?;
|
||||
// Offsets from where the anchor puts it (its paragraph may have moved with the edit).
|
||||
let origin = if on_page {
|
||||
wordcraft_geom::Point::new(0.0, 0.0)
|
||||
} else {
|
||||
s.touch(); // lay out the edit so far
|
||||
let layout = s.layout();
|
||||
layout.object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin
|
||||
};
|
||||
if on_page {
|
||||
return Ok(at);
|
||||
}
|
||||
// Then offsets from its paragraph, as laid out with the object there.
|
||||
s.touch();
|
||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||||
edit_float(s, &at, |f| {
|
||||
f.h_rel = Anchor::Column;
|
||||
f.v_rel = Anchor::Paragraph;
|
||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
f.y = (y - origin.y).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
})?;
|
||||
@@ -945,7 +1007,7 @@ fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
f(o);
|
||||
@@ -980,13 +1042,17 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
/// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays
|
||||
/// visible keeps its place on the page: a crop handle drop is one command.
|
||||
fn crop(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
|
||||
let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95));
|
||||
// The two sides of an axis keep at least 5% of the source between them, as the renderer
|
||||
// draws it: the second side stops where the first one already left less than that.
|
||||
c[2] = c[2].min(0.95 - c[0]);
|
||||
c[3] = c[3].min(0.95 - c[1]);
|
||||
let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0));
|
||||
let (nw, nh) = (size("width"), size("height"));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { crop, w, h, float, .. } = o {
|
||||
// The source's full size at the new display size (the visible part is 1-lo-hi of it).
|
||||
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.1);
|
||||
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05);
|
||||
// Flipped, the source's right (bottom) edge is the one on the page's left (top).
|
||||
let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 });
|
||||
if let Some(nw) = nw {
|
||||
@@ -1035,7 +1101,7 @@ pub fn original_size(s: &Session, media: &str) -> Option<OriginalSize> {
|
||||
pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> {
|
||||
let InlineObject::Image { media, crop, .. } = o else { return None };
|
||||
let orig = original_size(s, media)?;
|
||||
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.1);
|
||||
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05);
|
||||
Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3])))
|
||||
}
|
||||
|
||||
@@ -1611,7 +1677,7 @@ fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4],
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
pub(crate) fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
@@ -1724,6 +1790,103 @@ mod tests {
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
/// The body's top-level paragraphs, an object's character shown as ▣.
|
||||
fn body_text(s: &Session) -> String {
|
||||
(0..2)
|
||||
.filter_map(|i| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)))
|
||||
.map(|p| p.text.replace(wordcraft_doc::para::OBJ, "▣"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Two paragraphs, the second starting with an inline picture (bold, so its formatting can be
|
||||
/// followed), selected.
|
||||
fn inline_picture_session() -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "First paragraph."})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " second paragraph."})).unwrap();
|
||||
let pic = Pos::body(1, 0);
|
||||
s.run("select.range", &json!({"anchor": pic, "focus": Pos { off: pic.off + wordcraft_doc::para::OBJ.len_utf8(), ..pic.clone() }})).unwrap();
|
||||
assert!(selected(&s).is_some_and(|(_, o)| !o.is_floating()));
|
||||
s
|
||||
}
|
||||
|
||||
/// #2234: dragging an In Line with Text picture into the text (`to`) moves it there and it stays
|
||||
/// inline, with its formatting, selected, in one undo step; it never floats.
|
||||
#[test]
|
||||
fn an_inline_picture_dragged_into_the_text_stays_inline() {
|
||||
let mut s = inline_picture_session();
|
||||
let text = body_text;
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
// Into the first paragraph, after "First".
|
||||
let r = s.run("arrange.bounds", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||||
assert_eq!(r["object"]["float"]["wrap"], "inline", "{r}");
|
||||
let (pos, obj) = selected(&s).expect("still selected");
|
||||
assert_eq!(pos, Pos::body(0, 5));
|
||||
assert!(!obj.is_floating());
|
||||
assert!(s.doc.para_at(&pos).unwrap().props_of_char(pos.off).bold == Some(true), "keeps its run's formatting");
|
||||
// Later in its own paragraph (after "paragraph", counted with it still there): the offset
|
||||
// accounts for its own character leaving.
|
||||
s.run("arrange.bounds", &json!({"to": Pos::body(0, 18)})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph▣.\n second paragraph.");
|
||||
// Each move is one undo step.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
// Dropped on itself (either side): nothing changes.
|
||||
let here = selected(&s).unwrap().0;
|
||||
s.run("arrange.bounds", &json!({"to": here})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"to": Pos { off: here.off + wordcraft_doc::para::OBJ.len_utf8(), ..here.clone() }})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
assert_eq!(selected(&s).unwrap().0, here);
|
||||
}
|
||||
|
||||
/// `to` is only for In Line with Text objects, in their own text; bad targets change nothing.
|
||||
#[test]
|
||||
fn moving_into_the_text_refuses_bad_targets() {
|
||||
let mut s = inline_picture_session();
|
||||
let before = body_text(&s);
|
||||
for bad in [
|
||||
json!({"to": "junk"}),
|
||||
json!({"to": {"story": "body", "path": [9], "off": 0}}),
|
||||
json!({"to": Pos::body(0, 0), "x": 10, "y": 10}),
|
||||
json!({"to": {"story": {"part": 99}, "path": [0], "off": 0}}),
|
||||
] {
|
||||
assert!(s.run("arrange.bounds", &bad).is_err(), "{bad}");
|
||||
assert!(s.run("arrange.copyTo", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// A huge offset is clamped to the paragraph's end.
|
||||
s.run("arrange.bounds", &json!({"to": {"story": "body", "path": [0], "off": 999_999}})).unwrap();
|
||||
assert_eq!(body_text(&s), "First paragraph.▣\n second paragraph.");
|
||||
assert_ne!(body_text(&s), before);
|
||||
// A floating object is moved by x/y, not into the text.
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 300})).unwrap();
|
||||
assert!(s.run("arrange.bounds", &json!({"to": Pos::body(1, 0)})).is_err());
|
||||
}
|
||||
|
||||
/// Ctrl+drag of an inline picture into the text: a copy lands there, inline and selected; the
|
||||
/// original stays.
|
||||
#[test]
|
||||
fn copying_an_inline_picture_into_the_text() {
|
||||
let mut s = inline_picture_session();
|
||||
s.clipboard_text = "keep".into();
|
||||
s.run("arrange.copyTo", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||||
assert_eq!(body_text(&s), "First▣ paragraph.\n▣ second paragraph.");
|
||||
let (pos, obj) = selected(&s).expect("the copy is selected");
|
||||
assert_eq!(pos, Pos::body(0, 5));
|
||||
assert!(!obj.is_floating());
|
||||
assert_eq!(s.clipboard_text, "keep");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(body_text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_clicked_resized_and_dragged() {
|
||||
for kind in ["rectangle", "ellipse", "star", "arrow"] {
|
||||
@@ -2273,14 +2436,45 @@ mod tests {
|
||||
assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1]));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(image(&s), (200.0, 100.0, [0.0; 4]));
|
||||
// Clamps: each side at most 0.45; hostile sizes are ignored or clamped, never applied raw.
|
||||
// Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw.
|
||||
s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap();
|
||||
let (w, h, crop) = image(&s);
|
||||
assert_eq!(crop, [0.45, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(w, 200.0);
|
||||
assert!(h >= 1.0, "{h}");
|
||||
}
|
||||
|
||||
/// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to
|
||||
/// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%.
|
||||
#[test]
|
||||
fn a_picture_can_be_cropped_to_any_part_of_itself() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
s.run("picture.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||||
let crop = |s: &Session| match selected(s) {
|
||||
Some((_, InlineObject::Image { crop, .. })) => crop,
|
||||
other => panic!("expected image, got {other:?}"),
|
||||
};
|
||||
let left = |s: &Session| {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
super::cropped_original_size(s, &o).unwrap().0
|
||||
};
|
||||
// 90% off the left keeps the rightmost tenth: the side is not held at 45%.
|
||||
s.run("picture.crop", &json!({"left": 0.9})).unwrap();
|
||||
assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]);
|
||||
assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s));
|
||||
// With the left at 0.9 the right can take only 5% more, and what the crop leaves of the
|
||||
// original follows the crop down to that 5%.
|
||||
s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap();
|
||||
let c = crop(&s);
|
||||
assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}");
|
||||
assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s));
|
||||
// A single side past the cap stops at 0.95.
|
||||
s.run("picture.crop", &json!({"right": 4.0})).unwrap();
|
||||
assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]);
|
||||
}
|
||||
|
||||
/// Cropping a floating picture's left or top with its new size keeps the visible part where
|
||||
/// it is on the page: the frame moves by the trimmed amount, in the same undo step.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag
|
||||
|
||||
/// Longest initials kept, in characters.
|
||||
pub const MAX_INITIALS_CHARS: usize = 9;
|
||||
/// Longest dictionaries folder path taken, in characters.
|
||||
/// Longest folder path (dictionaries, user templates) taken, in characters.
|
||||
const MAX_FOLDER_CHARS: usize = 4096;
|
||||
/// Longest word the custom dictionary takes, in characters.
|
||||
pub const MAX_WORD_CHARS: usize = 64;
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "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, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "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| {
|
||||
@@ -117,9 +117,15 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let folder = match v.get("dictionariesFolder") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) => Some(dictionaries_folder(f)?),
|
||||
Some(Value::String(f)) => Some(folder_setting("dictionariesFolder", f, false)?),
|
||||
Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())),
|
||||
};
|
||||
let templates_folder = match v.get("userTemplatesFolder") {
|
||||
None | Some(Value::Null) => None,
|
||||
// Never a folder on another machine (#2852): listing it would make Windows authenticate there.
|
||||
Some(Value::String(f)) => Some(folder_setting("userTemplatesFolder", f, true)?),
|
||||
Some(_) => return Err(CmdError::Params("`userTemplatesFolder` must be a folder path, or auto".into())),
|
||||
};
|
||||
let mut flag_values = Vec::new();
|
||||
for (k, f) in flags() {
|
||||
if let Some(b) = bool_param(v, k)? {
|
||||
@@ -147,6 +153,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|| units.is_some()
|
||||
|| region.is_some()
|
||||
|| folder.is_some()
|
||||
|| templates_folder.is_some()
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
@@ -168,6 +175,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use_dictionaries_folder(f.as_deref());
|
||||
s.prefs.dictionaries_folder = f;
|
||||
}
|
||||
if let Some(f) = templates_folder {
|
||||
s.prefs.user_templates_folder = f;
|
||||
}
|
||||
for (f, b) in flag_values {
|
||||
*f(&mut s.prefs) = b;
|
||||
}
|
||||
@@ -176,6 +186,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
if let Some(b) = check_spelling {
|
||||
s.view.proofing = b;
|
||||
s.prefs.check_spelling = b;
|
||||
}
|
||||
for w in &add {
|
||||
wordcraft_proof::add_word(w);
|
||||
@@ -221,6 +232,7 @@ pub fn current(s: &Session) -> Value {
|
||||
"ignoreInternet": pr.ignore_internet,
|
||||
"dictionary": wordcraft_proof::user_dictionary(),
|
||||
"dictionariesFolder": pr.dictionaries_folder,
|
||||
"userTemplatesFolder": pr.user_templates_folder,
|
||||
"dictionaries": wordcraft_proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
|
||||
@@ -228,23 +240,28 @@ pub fn current(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing
|
||||
/// folder, given as an absolute path or one starting with `~/`.
|
||||
fn dictionaries_folder(f: &str) -> Result<Option<String>, CmdError> {
|
||||
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
|
||||
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
|
||||
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
|
||||
/// that it exists would make Windows authenticate to that host.
|
||||
fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>, CmdError> {
|
||||
let f = f.trim();
|
||||
if f.is_empty() || f.eq_ignore_ascii_case("auto") {
|
||||
return Ok(None);
|
||||
}
|
||||
if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) {
|
||||
return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into()));
|
||||
return Err(CmdError::Params(format!("`{key}` isn't a folder path")));
|
||||
}
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
let path = match (f.strip_prefix("~/"), home) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => std::path::PathBuf::from(f),
|
||||
};
|
||||
if local_only && (super::templates::is_network_path(f) || super::templates::is_network_path(&path.to_string_lossy())) {
|
||||
return Err(CmdError::Params(format!("`{key}` can't be a network folder")));
|
||||
}
|
||||
if !path.is_absolute() {
|
||||
return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path")));
|
||||
return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
|
||||
|
||||
@@ -72,15 +72,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| {
|
||||
let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml);
|
||||
let start = p::u64(v, "start").map(|x| x.min(100_000) as u32);
|
||||
with_sect(s, |x| {
|
||||
if let Some(f) = fmt {
|
||||
x.page_num_format = f;
|
||||
}
|
||||
x.page_num_start = start;
|
||||
// Left out entirely: the numbering keeps its start. Given as null: the pages
|
||||
// number on from the section before (the dialog's "Continue from previous
|
||||
// section"). A number: numbering starts there.
|
||||
if v.get("start").is_some() {
|
||||
x.page_num_start = p::u64(v, "start").map(|n| n.min(100_000) as u32);
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#),
|
||||
.params(
|
||||
r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n | null} ("start" left out: the numbering keeps its start; null: the pages number on from the section before; a number: numbering starts there)"#,
|
||||
),
|
||||
CommandSpec::new("layout.documentGrid", "Document Grid", "Layout › Page Setup › Document Grid", document_grid).params(
|
||||
r#"{"type"?: "none|lines|linesAndChars|snapToChars", "linesPerPage"?: n (sets the line pitch from the text height), "charsPerLine"?: n (sets the character pitch from the text width), "linePitch"?: pt, "charSpace"?: n (4096ths of a point added to the default font size: the character pitch)} — left out stays as it is; returns the grid with its lines per page and characters per line"#,
|
||||
),
|
||||
@@ -234,7 +240,7 @@ fn section_of_part(s: &Session, id: u32) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn section_index_of_focus(s: &Session) -> usize {
|
||||
pub fn section_index_of_focus(s: &Session) -> usize {
|
||||
match s.sel.focus.story {
|
||||
wordcraft_doc::StoryRef::Body => s.doc.section_index_of(s.sel.focus.path.0.first().copied().unwrap_or(0) as usize),
|
||||
wordcraft_doc::StoryRef::Part(id) => section_of_part(s, id),
|
||||
@@ -1171,4 +1177,27 @@ mod tests {
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found);
|
||||
assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true);
|
||||
}
|
||||
|
||||
/// `layout.pageNumberFormat` sets the section's number format and start; a call without
|
||||
/// `start` keeps it (the ribbon's old plain call cleared it), and a null `start` numbers on
|
||||
/// from the section before — the Format Page Numbers dialog's "Continue from previous
|
||||
/// section" (#1113).
|
||||
#[test]
|
||||
fn page_number_format_keeps_or_clears_the_start() {
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let mut s = doc();
|
||||
s.run("layout.pageNumberFormat", &json!({"format": "upperRoman", "start": 3})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::UpperRoman, Some(3)));
|
||||
s.run("layout.pageNumberFormat", &json!({"format": "lowerLetter"})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, Some(3)), "a format change keeps the start");
|
||||
s.run("layout.pageNumberFormat", &json!({"start": null})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, None), "continue numbers on from the section before");
|
||||
// The result carries the section back (scripts read it instead of the dialog).
|
||||
let back = s.run("layout.pageNumberFormat", &json!({"format": "decimal", "start": 1})).unwrap();
|
||||
assert_eq!(back["pageNumFormat"], "decimal");
|
||||
assert_eq!(back["pageNumStart"], 1);
|
||||
}
|
||||
}
|
||||
@@ -510,17 +510,20 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
|
||||
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
|
||||
let off = p::bool(v, "off").unwrap_or(false);
|
||||
let a = s.sel.ordered().0;
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> =
|
||||
super::selected_paths(s).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let current: Vec<Option<NumRef>> = super::selected_paths(s)
|
||||
.iter()
|
||||
.map(|p| s.doc.para(a.story, p).and_then(|x| s.doc.styles.resolve_para(&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| {
|
||||
s.doc.numbering.level(n.num, n.level).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
want.first().is_some_and(|w| {
|
||||
want.get(usize::from(n.level)).is_some_and(|w| {
|
||||
(w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet)
|
||||
&& (p::str(v, "kind").is_none() || w.text == l.text)
|
||||
})
|
||||
@@ -528,13 +531,14 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
};
|
||||
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
|
||||
if remove {
|
||||
let keep_list_style = s.doc.styles.resolve_para(&ParaProps { style: Some("ListParagraph".into()), ..Default::default() }).numbering.is_some();
|
||||
return fmt(s, &|p| {
|
||||
p.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.indent_left = None;
|
||||
p.indent_first = None;
|
||||
p.indent_left_chars = None;
|
||||
p.indent_first_chars = None;
|
||||
if p.style.as_deref() == Some("ListParagraph") {
|
||||
if p.style.as_deref() == Some("ListParagraph") && !keep_list_style {
|
||||
p.style = None;
|
||||
}
|
||||
});
|
||||
@@ -544,7 +548,7 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
.doc
|
||||
.prev_para(a.story, &a.path)
|
||||
.and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering))
|
||||
.filter(|n| n.num != 0 && same_kind(n));
|
||||
.filter(|n| n.num != 0 && same_kind(&NumRef { level: 0, ..*n }));
|
||||
let num = match prev_num {
|
||||
Some(n) => n.num,
|
||||
None => match kind {
|
||||
|
||||
@@ -207,6 +207,23 @@ fn always_allowed(id: &str) -> bool {
|
||||
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
|
||||
}
|
||||
|
||||
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
|
||||
/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame.
|
||||
/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons
|
||||
/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control.
|
||||
/// The form's fields stay as they are; only their content is filled in.
|
||||
pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
|
||||
if s.doc.settings.protection.as_deref() != Some("forms") {
|
||||
return None;
|
||||
}
|
||||
match id {
|
||||
"developer.removeControl" => Some("stop protection before removing a content control"),
|
||||
"developer.properties" => Some("stop protection before changing a content control's properties"),
|
||||
_ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)`
|
||||
/// lets it run as long as it only changes the exceptions (checked afterwards with
|
||||
/// [`outside_unchanged`]).
|
||||
@@ -223,19 +240,8 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
|
||||
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
|
||||
}
|
||||
// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control
|
||||
// buttons and Properties are greyed out and a field's shortcut menu offers no Remove Content
|
||||
// Control. The form's fields stay as they are; only their content is filled in.
|
||||
if mode == "forms" {
|
||||
if id == "developer.removeControl" {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before removing a content control")));
|
||||
}
|
||||
if id == "developer.properties" {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's properties")));
|
||||
}
|
||||
if CONTROL_INSERTS.contains(&id) {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before inserting a content control")));
|
||||
}
|
||||
if let Some(why) = greyed_out(s, id) {
|
||||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||||
}
|
||||
if s.doc.settings.protect_formatting {
|
||||
check_formatting(s, id, params)?;
|
||||
@@ -766,6 +772,27 @@ mod tests {
|
||||
assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
|
||||
}
|
||||
|
||||
/// The ribbon greys out what forms mode refuses outright (as Word for Mac does), and only that:
|
||||
/// typing into the field and the rest of the ribbon stay live.
|
||||
#[test]
|
||||
fn forms_mode_greys_out_the_control_commands() {
|
||||
let mut s = Session::new(Document::from_text("Name: Fill name."));
|
||||
select(&mut s, "Fill name.");
|
||||
s.run("developer.richText", &json!({"tag": "name"})).unwrap();
|
||||
let greyed = ["developer.removeControl", "developer.properties"].into_iter().chain(CONTROL_INSERTS);
|
||||
assert!(greyed.clone().all(|id| s.why_disabled(id).is_none()), "unprotected");
|
||||
s.run("review.restrict", &json!({"mode": "forms"})).unwrap();
|
||||
for id in greyed {
|
||||
let why = s.why_disabled(id).unwrap_or_default();
|
||||
assert!(why.contains("stop protection"), "{id}: {why:?}");
|
||||
}
|
||||
for id in ["text.insert", "format.bold", "review.restrict", "view.zoom"] {
|
||||
assert_eq!(s.why_disabled(id), None, "{id}");
|
||||
}
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
assert_eq!(s.why_disabled("developer.removeControl"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_mode_without_fillable_controls_stays_locked() {
|
||||
let mut s = Session::new(Document::from_text("No controls here."));
|
||||
|
||||
@@ -149,6 +149,11 @@ impl FieldCode {
|
||||
self.switches.iter().any(|(k, _)| *k == c)
|
||||
}
|
||||
|
||||
/// A table of figures (`TOC \c` or `TOC \a`, ECMA-376 §17.16.5.68), not a table of contents.
|
||||
pub fn is_table_of_figures(&self) -> bool {
|
||||
self.name == "TOC" && (self.has('c') || self.has('a'))
|
||||
}
|
||||
|
||||
/// The argument of switch `c`, if the switch is there with one.
|
||||
pub fn arg(&self, c: char) -> Option<&str> {
|
||||
self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref())
|
||||
@@ -360,7 +365,7 @@ fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
|
||||
let field = FieldCode::parse(&r.instr);
|
||||
toc_range(s).is_none()
|
||||
&& field.name == "TOC"
|
||||
&& !field.has('c')
|
||||
&& !field.is_table_of_figures()
|
||||
&& r.depth == 0
|
||||
&& !r.locked
|
||||
&& r.start.path.depth() == 0
|
||||
@@ -425,7 +430,7 @@ fn toc_field(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let f = FieldCode::parse(instr);
|
||||
(f.name == "TOC" && !f.has('c')).then(|| instr.clone())
|
||||
(f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -436,7 +441,7 @@ fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
|
||||
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
|
||||
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
|
||||
let f = FieldCode::parse(&r.instr);
|
||||
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c'))
|
||||
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.is_table_of_figures())
|
||||
.then_some((r.start.path.last(), r.end.path.last()))
|
||||
})
|
||||
});
|
||||
@@ -560,7 +565,7 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name == "TOC" && !f.has('c') {
|
||||
if f.name == "TOC" && !f.is_table_of_figures() {
|
||||
result.clear();
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1018,19 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
"TITLE" => updates.push((k, s.doc.core.title.clone())),
|
||||
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
|
||||
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
|
||||
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
|
||||
"DOCPROPERTY" => {
|
||||
if let Some(name) = doc_property_arg(instr)
|
||||
&& let Some(v) = doc_property(&s.doc, &name)
|
||||
{
|
||||
updates.push((k, v));
|
||||
}
|
||||
}
|
||||
"FILENAME" => {
|
||||
let (fn_name, fn_path) = s.resolved_filenames();
|
||||
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
|
||||
updates.push((k, text));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1092,38 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
|
||||
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
|
||||
fn doc_property_arg(instr: &str) -> Option<String> {
|
||||
let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start();
|
||||
let name = match rest.strip_prefix('"') {
|
||||
Some(q) => q.split('"').next().unwrap_or(""),
|
||||
None => rest.split([' ', '\\']).next().unwrap_or(""),
|
||||
};
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
|
||||
/// when one has this name, else the built-in property (English names, case-insensitively, as
|
||||
/// Word's field accepts). `None` for a name this application doesn't know.
|
||||
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
if let Some(v) = doc.custom_prop(name) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
let c = &doc.core;
|
||||
Some(match name.trim().to_ascii_lowercase().as_str() {
|
||||
"title" => c.title.clone(),
|
||||
"subject" => c.subject.clone(),
|
||||
"author" | "creator" => c.creator.clone(),
|
||||
"keywords" => c.keywords.clone(),
|
||||
"comments" | "description" => c.description.clone(),
|
||||
"category" => c.category.clone(),
|
||||
"lastmodifiedby" | "last modified by" | "lastsavedby" | "last saved by" => c.last_modified_by.clone(),
|
||||
"revisionnumber" | "revision number" | "revision" => c.revision.to_string(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word.
|
||||
pub fn date_picture(instr: &str) -> Option<String> {
|
||||
let rest = instr.split_once("\\@")?.1.trim_start();
|
||||
@@ -1325,6 +1375,48 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fields_filename() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.path = Some(std::path::PathBuf::from("/docs/my_thesis.docx"));
|
||||
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[0], ("FILENAME".into(), "my_thesis.docx".into()));
|
||||
|
||||
s.run("insert.field", &serde_json::json!({"instr": r"FILENAME \p"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[1], (r"FILENAME \p".into(), "/docs/my_thesis.docx".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fields_filename_titled_unsaved() {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.core.title = "Important Document".into();
|
||||
let mut s = Session::new(doc);
|
||||
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[0], ("FILENAME".into(), "Document1".into()));
|
||||
|
||||
let l = s.layout();
|
||||
let text: String = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let wordcraft_layout::Placed::Lines { para, .. } = it {
|
||||
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert!(text.contains("Document1"), "live layout renders Document1: {text}");
|
||||
assert!(!text.contains("Important Document"), "live layout must not render title: {text}");
|
||||
|
||||
s.run("references.updateFields", &serde_json::json!({})).unwrap();
|
||||
let fields_after = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields_after[0], ("FILENAME".into(), "Document1".into()));
|
||||
}
|
||||
|
||||
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
|
||||
#[test]
|
||||
fn update_fields_in_the_body_leaves_headers_alone() {
|
||||
|
||||
+271
-13
@@ -12,17 +12,21 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#).when(comments_change),
|
||||
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment)
|
||||
.params(r#"{"id": n, "text": string}"#)
|
||||
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply)
|
||||
.params(r#"{"id": non-negative JSON integer, "text": string}"#)
|
||||
.when(comments_change),
|
||||
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#),
|
||||
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment)
|
||||
.params(r#"{"id": non-negative JSON integer, "text": string}"#)
|
||||
.when(comments_change),
|
||||
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "all"?: bool}"#),
|
||||
CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| {
|
||||
validate_comment_id(v)?;
|
||||
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?;
|
||||
c.resolved = p::bool(v, "value").unwrap_or(!c.resolved);
|
||||
sel_result(s)
|
||||
}),
|
||||
})
|
||||
.params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "value"?: bool (omit to toggle)}"#),
|
||||
CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(),
|
||||
CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(),
|
||||
CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(),
|
||||
@@ -110,6 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.proofing", "Check Spelling as You Type", "File › Options › Proofing", |s, v| {
|
||||
s.view.proofing = p::bool(v, "value").unwrap_or(!s.view.proofing);
|
||||
s.prefs.check_spelling = s.view.proofing;
|
||||
Ok(json!({"value": s.view.proofing}))
|
||||
})
|
||||
.pure(),
|
||||
@@ -146,7 +151,17 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"id": id, "story": part}))
|
||||
}
|
||||
|
||||
/// An omitted ID permits caret targeting; an explicit malformed value must not.
|
||||
/// Unlike the general numeric parameter helper, comment IDs never coerce floats.
|
||||
fn validate_comment_id(v: &Value) -> Result<(), CmdError> {
|
||||
if v.get("id").is_some_and(|id| id.as_u64().is_none_or(|n| u32::try_from(n).is_err())) {
|
||||
return Err(CmdError::Params("`id` must be a non-negative 32-bit JSON integer".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
if !s.doc.comments.contains_key(&parent) {
|
||||
return Err(CmdError::Params("no such comment".into()));
|
||||
@@ -179,6 +194,7 @@ fn comments_change(s: &Session) -> Option<&'static str> {
|
||||
|
||||
/// Replace a comment's (or reply's) text; each line becomes a paragraph.
|
||||
fn edit_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
let text = p::req_str(v, "text")?;
|
||||
let part = s.doc.comments.get(&id).map(|c| c.part).ok_or_else(|| CmdError::Params("no such comment".into()))?;
|
||||
@@ -269,6 +285,7 @@ fn thread_of(s: &Session, id: u32) -> Vec<u32> {
|
||||
}
|
||||
|
||||
fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) {
|
||||
s.doc.comments.keys().copied().collect()
|
||||
} else {
|
||||
@@ -285,14 +302,22 @@ fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
/// Comments in document order with their anchors.
|
||||
/// Comments in reading order with their anchors: every story the reader sees, in
|
||||
/// `Document::shown_stories` order (#2289). A comment anchored only in a story nothing shows
|
||||
/// (a removed header) has no anchor.
|
||||
pub fn comment_list(s: &Session) -> Vec<(u32, Option<Pos>)> {
|
||||
let mut found: Vec<(u32, Option<Pos>)> = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for o in p.object_offsets() {
|
||||
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
|
||||
found.push((*id, Some(Pos { story: StoryRef::Body, path: path.clone(), off: o })));
|
||||
// The balloons ask every frame; most documents have no comments.
|
||||
if s.doc.comments.is_empty() {
|
||||
return found;
|
||||
}
|
||||
for story in s.doc.shown_stories() {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
for o in p.object_offsets() {
|
||||
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
|
||||
found.push((*id, Some(Pos { story, path: path.clone(), off: o })));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,8 +347,16 @@ fn list_comments(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let list: Vec<Pos> = comment_list(s).into_iter().filter_map(|(_, p)| p).collect();
|
||||
let caret = s.sel.focus.clone();
|
||||
let t = if dir > 0 { list.iter().find(|p| **p > caret).or(list.first()) } else { list.iter().rev().find(|p| **p < caret).or(list.last()) };
|
||||
// Reading order: the story's place in `shown_stories`, then the place in the story. Part ids
|
||||
// follow creation, not reading, so `Pos` order won't do across stories.
|
||||
let rank: BTreeMap<StoryRef, usize> = s.doc.shown_stories().into_iter().enumerate().map(|(i, st)| (st, i)).collect();
|
||||
let key = |p: &Pos| rank.get(&p.story).map(|i| (*i, p.path.clone(), p.off));
|
||||
let caret = key(&s.sel.focus);
|
||||
let t = if dir > 0 {
|
||||
list.iter().find(|p| caret.is_some() && key(p) > caret).or(list.first())
|
||||
} else {
|
||||
list.iter().rev().find(|p| caret.is_some() && key(p) < caret).or(list.last())
|
||||
};
|
||||
if let Some(p) = t {
|
||||
s.sel = Selection::caret(p.clone());
|
||||
}
|
||||
@@ -1747,6 +1780,231 @@ mod tests {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A comment on the whole of the story the caret is in; its id and that story.
|
||||
fn comment_here(s: &mut Session, text: &str) -> (u64, wordcraft_doc::StoryRef) {
|
||||
let story = s.sel.focus.story;
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
(s.run("review.newComment", &json!({"text": text})).unwrap()["id"].as_u64().unwrap(), story)
|
||||
}
|
||||
|
||||
/// Comments in a header, a footer, a text box and a footnote, made in that order, then one
|
||||
/// on a word of the body. Returns each comment's id and story in reading order, which isn't
|
||||
/// the order they were made: the body, the text box and footnote it shows, then the header
|
||||
/// and the footer.
|
||||
fn comments_in_every_story(s: &mut Session) -> Vec<(u64, wordcraft_doc::StoryRef)> {
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("insert.header", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
|
||||
let footer = comment_here(s, "footer");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.textBox", &json!({"text": "boxed"})).unwrap();
|
||||
let text_box = comment_here(s, "text box");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.footnote", &json!({"text": "noted"})).unwrap();
|
||||
let note = comment_here(s, "footnote");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
let body = (s.run("review.newComment", &json!({"text": "body"})).unwrap()["id"].as_u64().unwrap(), wordcraft_doc::StoryRef::Body);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
vec![body, text_box, note, header, footer]
|
||||
}
|
||||
|
||||
/// `review.comments` as (id, the anchor's story).
|
||||
fn listed(s: &mut Session) -> Vec<(u64, Option<wordcraft_doc::StoryRef>)> {
|
||||
let list = s.run("review.comments", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|c| (c["id"].as_u64().unwrap(), serde_json::from_value(c["anchor"]["story"].clone()).ok())).collect()
|
||||
}
|
||||
|
||||
/// The stories Next Comment visits, `n` presses from the caret.
|
||||
fn next_stories(s: &mut Session, n: usize) -> Vec<wordcraft_doc::StoryRef> {
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s.run("review.nextComment", &json!({})).unwrap();
|
||||
s.sel.focus.story
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #2289: comments in headers, footers, text boxes and footnotes are listed with their
|
||||
/// anchors, in reading order rather than the order their stories were made.
|
||||
#[test]
|
||||
fn comments_outside_the_body_are_listed_with_their_anchors() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(id, st)| (id, Some(st))).collect();
|
||||
assert_eq!(listed(&mut s), want);
|
||||
}
|
||||
|
||||
/// #2289: Next and Previous Comment go through every story's comments in reading order and
|
||||
/// wrap around.
|
||||
#[test]
|
||||
fn next_and_previous_comment_reach_comments_outside_the_body() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(_, st)| st).collect();
|
||||
let mut wrapped = want.clone();
|
||||
wrapped.push(want[0]);
|
||||
assert_eq!(next_stories(&mut s, want.len() + 1), wrapped, "Next visits each comment, then wraps to the first");
|
||||
let back: Vec<_> = (0..want.len())
|
||||
.map(|_| {
|
||||
s.run("review.previousComment", &json!({})).unwrap();
|
||||
s.sel.focus.story
|
||||
})
|
||||
.collect();
|
||||
let mut rev = want.clone();
|
||||
rev.reverse();
|
||||
assert_eq!(back, rev, "Previous from the first comment wraps to the last");
|
||||
}
|
||||
|
||||
/// #2289: a header comes before the footer whichever was made first.
|
||||
#[test]
|
||||
fn next_comment_reaches_the_header_before_the_footer() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
|
||||
let footer = comment_here(&mut s, "footer").1;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("insert.header", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(&mut s, "header").1;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(next_stories(&mut s, 2), vec![header, footer]);
|
||||
}
|
||||
|
||||
/// #2289: a comment left in a story nothing shows any more (a removed header, a deleted
|
||||
/// footnote's text) has no anchor, and Next never takes the caret into that hidden story.
|
||||
#[test]
|
||||
fn comments_in_hidden_stories_are_not_navigated_to() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let all = comments_in_every_story(&mut s);
|
||||
let (note, header) = (all[2], all[3]);
|
||||
s.run("insert.removeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.backspace", &json!({})).unwrap();
|
||||
assert!(!s.doc.shown_stories().contains(¬e.1), "the footnote's reference is gone");
|
||||
let list = listed(&mut s);
|
||||
for hidden in [note, header] {
|
||||
assert!(list.contains(&(hidden.0, None)), "comment {} sits in a hidden story: {list:?}", hidden.0);
|
||||
}
|
||||
let shown = [all[0].1, all[1].1, all[4].1];
|
||||
for st in next_stories(&mut s, all.len()) {
|
||||
assert!(shown.contains(&st), "Next went to hidden {st:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body comment, then, with `option` on, a comment in the header of page `page`; `option`
|
||||
/// is turned off again. Returns the header comment's id and story.
|
||||
fn comment_in_header_version(s: &mut Session, option: &str, page: usize) -> (u64, wordcraft_doc::StoryRef) {
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
s.run("review.newComment", &json!({"text": "body"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run(option, &json!({"value": true})).unwrap();
|
||||
s.run("insert.editHeader", &json!({"page": page})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run(option, &json!({"value": false})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
header
|
||||
}
|
||||
|
||||
/// A first-page or even-page header is hidden while its option is off: its comments have no
|
||||
/// anchor and Next skips them.
|
||||
#[test]
|
||||
fn comments_in_header_versions_whose_option_is_off_are_not_navigated_to() {
|
||||
for (option, page, slot) in [("layout.differentFirstPage", 0, "first"), ("layout.differentOddEven", 1, "even")] {
|
||||
let mut s = Session::new(Document::new());
|
||||
let (id, story) = comment_in_header_version(&mut s, option, page);
|
||||
let headers = s.doc.sections().first().map(|(_, sec)| sec.headers).unwrap();
|
||||
let want = if slot == "first" { headers.first } else { headers.even };
|
||||
assert_eq!(want.map(wordcraft_doc::StoryRef::Part), Some(story), "{option}: the comment is in the {slot} header");
|
||||
assert!(listed(&mut s).contains(&(id, None)), "{option} off: no anchor");
|
||||
for st in next_stories(&mut s, 3) {
|
||||
assert_eq!(st, wordcraft_doc::StoryRef::Body, "{option} off: Next went to the hidden {slot} header");
|
||||
}
|
||||
s.run(option, &json!({"value": true})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, Some(story))), "{option} on: anchored again");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Different First Page on, the first page's header comes before the later pages'
|
||||
/// header, in the list and for Next.
|
||||
#[test]
|
||||
fn first_page_header_comes_before_the_default_header() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Page one"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("layout.differentFirstPage", &json!({"value": true})).unwrap();
|
||||
let mut header = |page: usize| {
|
||||
s.run("insert.editHeader", &json!({"page": page})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "header"})).unwrap();
|
||||
let c = comment_here(&mut s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
c
|
||||
};
|
||||
let (first, default) = (header(0), header(1));
|
||||
assert_ne!(first.1, default.1);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
assert_eq!(listed(&mut s), vec![(first.0, Some(first.1)), (default.0, Some(default.1))]);
|
||||
assert_eq!(next_stories(&mut s, 2), vec![first.1, default.1]);
|
||||
}
|
||||
|
||||
/// A linked text box shows the first box's text: comments in its own text have no anchor and
|
||||
/// Next skips them.
|
||||
#[test]
|
||||
fn comments_in_a_linked_text_box_own_text_are_not_navigated_to() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
s.run("review.newComment", &json!({"text": "body"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.textBox", &json!({"text": "first box"})).unwrap();
|
||||
let first = s.sel.focus.story;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
// Only a blank box can be linked to.
|
||||
s.run("insert.textBox", &json!({"text": " "})).unwrap();
|
||||
let (id, follower) = comment_here(&mut s, "follower");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
|
||||
s.run("shape.link", &json!({"target": 1})).unwrap();
|
||||
assert_eq!(s.doc.text_box_chains().first().and_then(|c| c.get(1)).copied().map(wordcraft_doc::StoryRef::Part), Some(follower));
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, None)), "linked: no anchor");
|
||||
for st in next_stories(&mut s, 3) {
|
||||
assert_ne!(st, follower, "Next went into the linked box's own text");
|
||||
}
|
||||
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
|
||||
s.run("shape.breakLink", &json!({})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, Some(follower))), "unlinked: anchored again");
|
||||
}
|
||||
|
||||
/// Text inside a comment isn't an anchor: a comment marker in a comment's own story is not
|
||||
/// listed as that comment's place in the document.
|
||||
#[test]
|
||||
fn comment_list_skips_comment_text() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let id = s.run("review.newComment", &json!({"text": "note"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let part = s.doc.comments.get(&id).unwrap().part;
|
||||
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Part(part), path: wordcraft_doc::Path::top(0), off: 0 };
|
||||
s.doc.insert_object(&at, wordcraft_doc::InlineObject::CommentStart { id }, &Default::default()).unwrap();
|
||||
let list = super::comment_list(&s);
|
||||
assert_eq!(list.iter().filter(|(x, _)| *x == id).count(), 1);
|
||||
assert_eq!(list.first().and_then(|(_, p)| p.as_ref()).map(|p| p.story), Some(wordcraft_doc::StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() {
|
||||
// Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Shape Format › Shape Fill (#1500): a solid colour with its transparency, or a gradient, for
|
||||
//! every selected shape, text box and drawing canvas.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Pos;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
|
||||
use super::objects::{edit_obj, picked_objects, selected_all};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Most shapes one command fills (a selection over a whole document).
|
||||
const MAX_FILL_SHAPES: usize = 10_000;
|
||||
/// Most stops read from a command (the fill keeps at most `MAX_STOPS` after sanitizing).
|
||||
const MAX_PARAM_STOPS: usize = 64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", shape_fill)
|
||||
.params(
|
||||
r#"{"color": "RRGGBB" | null, "transparency"?: %} | {"gradient": {"stops": [{"pos": %, "color": "RRGGBB", "transparency"?: %}], "type"?: "linear|radial|rectangular|path", "angle"?: deg, "focus"?: [l, t, r, b], "tile"?: [l, t, r, b], "scaled"?: bool, "rotateWithShape"?: bool}} (tile: percent insets of the gradient's area; scaled: a linear angle scaled with the shape; a colour alone keeps the shape's transparency; angle: linear only, 0 = left to right, 90 = top to bottom; focus: percent insets of where a radial, rectangular or path gradient starts; applies to every selected shape, text box and canvas background)"#,
|
||||
)
|
||||
.when(super::objects::has_shape_or_canvas),
|
||||
]
|
||||
}
|
||||
|
||||
/// What a `shape.fill` command sets.
|
||||
enum Change {
|
||||
None,
|
||||
/// A colour, with its transparency or (`None`) keeping each shape's.
|
||||
Solid(Rgb, Option<f32>),
|
||||
Fill(Fill),
|
||||
}
|
||||
|
||||
fn shape_fill(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let change = parse(v)?;
|
||||
let targets = targets(s);
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
let mut last = Value::Null;
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
let slot = match o {
|
||||
InlineObject::Shape { fill, .. } => fill,
|
||||
o => match super::canvas::style_mut(o) {
|
||||
Some(style) => &mut style.fill,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
*slot = match &change {
|
||||
Change::None => None,
|
||||
Change::Fill(f) => Some(f.clone()),
|
||||
Change::Solid(color, t) => {
|
||||
let kept = match slot {
|
||||
Some(Fill::Solid { transparency, .. }) => *transparency,
|
||||
_ => 0.0,
|
||||
};
|
||||
Fill::Solid { color: *color, transparency: t.unwrap_or(kept) }.sanitized()
|
||||
}
|
||||
};
|
||||
})?;
|
||||
last = serde_json::to_value(o).unwrap_or(Value::Null);
|
||||
}
|
||||
Ok(serde_json::json!({"object": last, "count": targets.len()}))
|
||||
}
|
||||
|
||||
/// The shapes and canvases in the selection and those added to it (Shift+click).
|
||||
fn targets(s: &Session) -> Vec<Pos> {
|
||||
let fillable = |o: &InlineObject| matches!(o, InlineObject::Shape { .. }) || o.is_canvas();
|
||||
let mut out: Vec<Pos> = selected_all(s, MAX_FILL_SHAPES).into_iter().filter(|(_, o)| fillable(o)).map(|(pos, _)| pos).collect();
|
||||
for pos in picked_objects(s) {
|
||||
if !out.contains(&pos) && s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)).is_some_and(fillable) {
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn color(v: &Value) -> Result<Rgb, CmdError> {
|
||||
p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color`: \"RRGGBB\"".into()))
|
||||
}
|
||||
|
||||
fn parse(v: &Value) -> Result<Change, CmdError> {
|
||||
let Some(g) = v.get("gradient") else {
|
||||
return match v.get("color") {
|
||||
None | Some(Value::Null) => Ok(Change::None),
|
||||
Some(_) => Ok(Change::Solid(color(v)?, p::f32(v, "transparency"))),
|
||||
};
|
||||
};
|
||||
let bad = |m: &str| CmdError::Params(format!("`gradient`: {m}"));
|
||||
if !g.is_object() {
|
||||
return Err(bad("an object"));
|
||||
}
|
||||
let stops = g
|
||||
.get("stops")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("`stops` is a list"))?
|
||||
.iter()
|
||||
.take(MAX_PARAM_STOPS)
|
||||
.map(|st| Ok(GradStop { pos: p::f32(st, "pos").unwrap_or(0.0), color: color(st)?, transparency: p::f32(st, "transparency").unwrap_or(0.0) }))
|
||||
.collect::<Result<Vec<_>, CmdError>>()?;
|
||||
if stops.len() < 2 {
|
||||
return Err(bad("two or more stops"));
|
||||
}
|
||||
let rect = |key: &str, default: f32| -> Option<[f32; 4]> {
|
||||
let a = g.get(key)?.as_array()?;
|
||||
let mut r = [default; 4];
|
||||
for (slot, x) in r.iter_mut().zip(a) {
|
||||
*slot = x.as_f64().map_or(default, |x| x as f32);
|
||||
}
|
||||
Some(r)
|
||||
};
|
||||
let path = |shape| GradientKind::Path { shape, focus: rect("focus", 50.0).unwrap_or([50.0; 4]) };
|
||||
let kind = match p::str(g, "type").unwrap_or("linear") {
|
||||
"linear" => {
|
||||
GradientKind::Linear { angle: p::f32(g, "angle").unwrap_or(90.0), scaled: g.get("scaled").and_then(Value::as_bool).unwrap_or(false) }
|
||||
}
|
||||
"radial" => path(PathShape::Circle),
|
||||
"rectangular" => path(PathShape::Rect),
|
||||
"path" => path(PathShape::Shape),
|
||||
t => return Err(bad(&format!("unknown type `{t}`"))),
|
||||
};
|
||||
let rot_with_shape = g.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true);
|
||||
let fill = Fill::Gradient(Arc::new(Gradient { stops, kind, tile: rect("tile", 0.0), rot_with_shape, flip: GradientFlip::None }));
|
||||
fill.sanitized().map(Change::Fill).ok_or_else(|| bad("no stops"))
|
||||
}
|
||||
|
||||
/// The `shape.fill` params that set `fill` (`None`: no fill).
|
||||
pub fn params(fill: Option<&Fill>) -> Value {
|
||||
use serde_json::json;
|
||||
match fill {
|
||||
None => json!({"color": null}),
|
||||
Some(Fill::Solid { color, transparency }) => json!({"color": color.hex(), "transparency": transparency}),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
let stops: Vec<Value> = g.stops.iter().map(|s| json!({"pos": s.pos, "color": s.color.hex(), "transparency": s.transparency})).collect();
|
||||
let mut out = json!({"stops": stops, "rotateWithShape": g.rot_with_shape});
|
||||
if let Some(t) = g.tile {
|
||||
out["tile"] = json!(t);
|
||||
}
|
||||
match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
out["type"] = json!("linear");
|
||||
out["angle"] = json!(angle);
|
||||
out["scaled"] = json!(scaled);
|
||||
}
|
||||
GradientKind::Path { shape, focus } => {
|
||||
out["type"] = json!(match shape {
|
||||
PathShape::Circle => "radial",
|
||||
PathShape::Rect => "rectangular",
|
||||
PathShape::Shape => "path",
|
||||
});
|
||||
out["focus"] = json!(focus);
|
||||
}
|
||||
}
|
||||
json!({"gradient": out})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The directions Format Shape offers for a linear gradient, as angles (0 = left to right, 90 =
|
||||
/// top to bottom).
|
||||
pub const LINEAR_DIRECTIONS: [f32; 8] = [45.0, 90.0, 135.0, 0.0, 180.0, 315.0, 270.0, 225.0];
|
||||
|
||||
/// The directions Format Shape offers for radial and rectangular gradients, as where they start
|
||||
/// (percent insets, left, top, right, bottom): each corner, then the centre.
|
||||
pub const PATH_DIRECTIONS: [[f32; 4]; 5] =
|
||||
[[100.0, 100.0, 0.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 50.0, 50.0, 50.0], [100.0, 0.0, 0.0, 100.0], [0.0, 0.0, 100.0, 100.0]];
|
||||
|
||||
fn grad(stops: &[(f32, Rgb)], kind: GradientKind) -> Fill {
|
||||
let stops = stops.iter().map(|&(pos, color)| GradStop { pos, color, transparency: 0.0 }).collect();
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||||
}
|
||||
|
||||
/// The gradient Format Shape's Gradient fill starts from, from the theme's first accent
|
||||
/// whatever the shape's colour (as Word 365 does): four stops, 0% at 95% lighter, 74% and 83%
|
||||
/// at 55% lighter, 100% at 70% lighter, top to bottom, scaled with the shape.
|
||||
pub fn default_gradient(accent1: Rgb) -> Fill {
|
||||
let lighter = |off: f64| {
|
||||
let c = accent1.modified("lumMod", 1.0 - off);
|
||||
c.modified("lumOff", off)
|
||||
};
|
||||
let stops = [(0.0, 0.95), (74.0, 0.55), (83.0, 0.55), (100.0, 0.7)]
|
||||
.into_iter()
|
||||
.map(|(pos, off)| GradStop { pos, color: lighter(off), transparency: 0.0 })
|
||||
.collect();
|
||||
let g = Gradient { stops, kind: GradientKind::Linear { angle: 90.0, scaled: true }, tile: None, rot_with_shape: true, flip: GradientFlip::None };
|
||||
Fill::Gradient(Arc::new(g))
|
||||
}
|
||||
|
||||
/// Where each corner tile's gradient starts (percent insets) and the tile rectangle Word gives
|
||||
/// it: from the bottom-right, bottom-left, top-right and top-left corners. Observed for the
|
||||
/// bottom-right corner (`fillToRect l=t=100%`, `tileRect r=b=-100%`); the others mirror it.
|
||||
const CORNERS: [([f32; 4], [f32; 4]); 4] = [
|
||||
([100.0, 100.0, 0.0, 0.0], [0.0, 0.0, -100.0, -100.0]),
|
||||
([0.0, 100.0, 100.0, 0.0], [-100.0, 0.0, 0.0, -100.0]),
|
||||
([100.0, 0.0, 0.0, 100.0], [0.0, -100.0, -100.0, 0.0]),
|
||||
([0.0, 0.0, 100.0, 100.0], [-100.0, -100.0, 0.0, 0.0]),
|
||||
];
|
||||
|
||||
/// The tile rectangle Word gives a radial or rectangular gradient starting at `focus`: a corner's,
|
||||
/// or none from the centre.
|
||||
pub fn corner_tile(focus: [f32; 4]) -> Option<[f32; 4]> {
|
||||
CORNERS.iter().find(|(f, _)| *f == focus).map(|(_, t)| *t)
|
||||
}
|
||||
|
||||
/// Shape Fill › Gradient's Light and Dark Variations of `c`, each with its name, in Word's
|
||||
/// order (a grid five wide): three stops at 0, 50 and 100%, light ones `c` tinted to 66, 44.5
|
||||
/// and 23.5% with saturation ×1.6, dark ones shaded to 30, 67.5 and 100% with saturation
|
||||
/// ×1.15 (as Word writes them); linear ones scaled with the shape, the rest circular.
|
||||
pub fn variations(c: Rgb) -> [Vec<(Fill, String)>; 2] {
|
||||
let tiles = |m: &str, steps: [f64; 3], sat: f64| -> Vec<(Fill, String)> {
|
||||
let stop = |i: usize| {
|
||||
let color = c.modified(m, steps[i]).modified("satMod", sat);
|
||||
GradStop { pos: i as f32 * 50.0, color, transparency: 0.0 }
|
||||
};
|
||||
let fill = |kind, tile| {
|
||||
let g = Gradient { stops: (0..3).map(stop).collect(), kind, tile, rot_with_shape: true, flip: GradientFlip::None };
|
||||
Fill::Gradient(Arc::new(g))
|
||||
};
|
||||
let lin = |angle: f32| (fill(GradientKind::Linear { angle, scaled: true }, None), linear_direction_name(angle));
|
||||
let corner = |i: usize| {
|
||||
let (focus, tile) = CORNERS[i];
|
||||
(fill(GradientKind::Path { shape: PathShape::Circle, focus }, Some(tile)), path_direction_name(focus))
|
||||
};
|
||||
let centre = (fill(GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] }, None), path_direction_name([50.0; 4]));
|
||||
vec![
|
||||
lin(45.0),
|
||||
lin(90.0),
|
||||
lin(135.0),
|
||||
corner(0),
|
||||
corner(1),
|
||||
lin(0.0),
|
||||
centre,
|
||||
lin(180.0),
|
||||
corner(2),
|
||||
corner(3),
|
||||
lin(315.0),
|
||||
lin(270.0),
|
||||
lin(225.0),
|
||||
]
|
||||
};
|
||||
[tiles("tint", [0.66, 0.445, 0.235], 1.6), tiles("shade", [0.3, 0.675, 1.0], 1.15)]
|
||||
}
|
||||
|
||||
/// The 30 preset gradients from the theme's six accents: per accent, soft, lit from the top,
|
||||
/// deep, lit from the bottom and centre glow. Our own design (the repo's clean-room rule: no
|
||||
/// copying Word's presets), on the same accents-by-styles grid as Word's.
|
||||
pub fn presets(theme: &[Rgb]) -> Vec<Fill> {
|
||||
let accent = |i: usize| theme.get(4 + i).copied().unwrap_or(Rgb(68, 114, 196));
|
||||
let down = GradientKind::Linear { angle: 90.0, scaled: false };
|
||||
let mut out = Vec::with_capacity(30);
|
||||
for style in 0..5 {
|
||||
for i in 0..6 {
|
||||
let c = accent(i);
|
||||
out.push(match style {
|
||||
0 => grad(&[(0.0, c.modified("tint", 0.2)), (100.0, c.modified("tint", 0.6))], down),
|
||||
1 => grad(&[(0.0, c.modified("tint", 0.4)), (50.0, c), (100.0, c.modified("shade", 0.75))], down),
|
||||
2 => grad(&[(0.0, c), (100.0, c.modified("shade", 0.5))], down),
|
||||
3 => grad(&[(0.0, c.modified("shade", 0.75)), (50.0, c), (100.0, c.modified("tint", 0.4))], down),
|
||||
_ => grad(
|
||||
&[(0.0, c.modified("tint", 0.4)), (100.0, c.modified("shade", 0.75))],
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] },
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The preset styles' names, in [`presets`]'s order (each for accents 1 to 6).
|
||||
pub const PRESET_STYLES: [&str; 5] = ["Soft", "Lit from Top", "Deep", "Lit from Bottom", "Center Glow"];
|
||||
|
||||
/// The name of the linear direction at `angle`.
|
||||
pub fn linear_direction_name(angle: f32) -> String {
|
||||
match angle.round() as i32 {
|
||||
0 => "Linear Right",
|
||||
45 => "Linear Diagonal - Top Left to Bottom Right",
|
||||
90 => "Linear Down",
|
||||
135 => "Linear Diagonal - Top Right to Bottom Left",
|
||||
180 => "Linear Left",
|
||||
225 => "Linear Diagonal - Bottom Right to Top Left",
|
||||
270 => "Linear Up",
|
||||
315 => "Linear Diagonal - Bottom Left to Top Right",
|
||||
_ => "Linear",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The name of the radial or rectangular direction starting at `focus`.
|
||||
pub fn path_direction_name(focus: [f32; 4]) -> String {
|
||||
match focus.map(|v| v.round() as i32) {
|
||||
[0, 0, 100, 100] => "From Top Left Corner",
|
||||
[100, 0, 0, 100] => "From Top Right Corner",
|
||||
[0, 100, 100, 0] => "From Bottom Left Corner",
|
||||
[100, 100, 0, 0] => "From Bottom Right Corner",
|
||||
_ => "From Center",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Stop editing for the Format Shape pane's gradient stops bar. Stops stay in position order; a
|
||||
/// gradient keeps 2 to `MAX_STOPS` of them.
|
||||
pub mod stops {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
/// The gradient's colour and transparency at `pos` (percent).
|
||||
pub fn at(stops: &[GradStop], pos: f32) -> (Rgb, f32) {
|
||||
let after = stops.iter().position(|s| s.pos >= pos);
|
||||
let (a, b) = match after {
|
||||
Some(0) | None => {
|
||||
let s = if after.is_none() { stops.last() } else { stops.first() };
|
||||
return s.map_or((Rgb::BLACK, 0.0), |s| (s.color, s.transparency));
|
||||
}
|
||||
Some(i) => (stops[i - 1], stops[i]),
|
||||
};
|
||||
let t = if b.pos > a.pos { (pos - a.pos) / (b.pos - a.pos) } else { 0.0 };
|
||||
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
|
||||
(Rgb(mix(a.color.0, b.color.0), mix(a.color.1, b.color.1), mix(a.color.2, b.color.2)), a.transparency + (b.transparency - a.transparency) * t)
|
||||
}
|
||||
|
||||
/// Adds a stop at `pos` in the gradient's colour there; its index, or `None` when full.
|
||||
pub fn add(stops: &mut Vec<GradStop>, pos: f32) -> Option<usize> {
|
||||
if stops.len() >= MAX_STOPS {
|
||||
return None;
|
||||
}
|
||||
let pos = if pos.is_finite() { pos.clamp(0.0, 100.0) } else { 50.0 };
|
||||
let (color, transparency) = at(stops, pos);
|
||||
let i = stops.iter().position(|s| s.pos > pos).unwrap_or(stops.len());
|
||||
stops.insert(i, GradStop { pos, color, transparency });
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Removes stop `i` unless only two are left; whether it did.
|
||||
pub fn remove(stops: &mut Vec<GradStop>, i: usize) -> bool {
|
||||
if stops.len() <= 2 || i >= stops.len() {
|
||||
return false;
|
||||
}
|
||||
stops.remove(i);
|
||||
true
|
||||
}
|
||||
|
||||
/// Moves stop `i` to `pos`, keeping the stops in order; its new index.
|
||||
pub fn move_to(stops: &mut Vec<GradStop>, i: usize, pos: f32) -> usize {
|
||||
if i >= stops.len() || !pos.is_finite() {
|
||||
return i;
|
||||
}
|
||||
let mut s = stops.remove(i);
|
||||
s.pos = pos.clamp(0.0, 100.0);
|
||||
let j = stops.iter().position(|o| o.pos > s.pos).unwrap_or(stops.len());
|
||||
stops.insert(j, s);
|
||||
j
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Change, params, parse, stops};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
|
||||
/// The variations Word writes for accent 1 (`tint`/`shade` with `satMod`, observed in Word 365).
|
||||
#[test]
|
||||
fn variations_follow_words() {
|
||||
use super::variations;
|
||||
let c = Rgb(0x15, 0x60, 0x82);
|
||||
let [light, dark] = variations(c);
|
||||
assert_eq!((light.len(), dark.len()), (13, 13));
|
||||
assert_eq!(light[1].1, "Linear Down");
|
||||
assert_eq!(light[6].1, "From Center");
|
||||
let Fill::Gradient(g) = &light[0].0 else { panic!() };
|
||||
assert_eq!(g.kind, GradientKind::Linear { angle: 45.0, scaled: true });
|
||||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0]);
|
||||
assert!(g.stops[0].color.0 > g.stops[2].color.0 || g.stops[0].color.2 < g.stops[2].color.2, "lighter towards the end: {:?}", g.stops);
|
||||
let Fill::Gradient(g) = &light[3].0 else { panic!() };
|
||||
assert_eq!(
|
||||
(g.kind, g.tile),
|
||||
(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 100.0, 0.0, 0.0] }, Some([0.0, 0.0, -100.0, -100.0]))
|
||||
);
|
||||
let Fill::Gradient(g) = super::default_gradient(c) else { panic!() };
|
||||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 74.0, 83.0, 100.0], "Word's starting gradient");
|
||||
assert_eq!(g.stops[1].color, g.stops[2].color);
|
||||
assert!(g.stops.iter().all(|s| s.color.0 > c.0 && s.color.1 > c.1), "lighter than the accent");
|
||||
let Fill::Gradient(g) = &dark[1].0 else { panic!() };
|
||||
assert_eq!(g.stops[2].color, c.modified("satMod", 1.15), "the last dark stop is the colour itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_set_the_fill_they_were_made_from() {
|
||||
let g = |kind| {
|
||||
Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![st(0.0, 10, 0.0), st(40.0, 90, 25.0), st(100.0, 250, 100.0)],
|
||||
kind,
|
||||
tile: None,
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::None,
|
||||
}))
|
||||
};
|
||||
let fills = [
|
||||
Fill::Solid { color: Rgb(1, 2, 3), transparency: 35.0 },
|
||||
g(GradientKind::Linear { angle: 135.0, scaled: false }),
|
||||
g(GradientKind::Linear { angle: 45.0, scaled: true }),
|
||||
g(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 0.0, 0.0, 100.0] }),
|
||||
g(GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }),
|
||||
g(GradientKind::Path { shape: PathShape::Shape, focus: [50.0; 4] }),
|
||||
];
|
||||
let tiled = super::variations(Rgb(9, 9, 9))[0][3].0.clone();
|
||||
for f in fills.into_iter().chain([tiled]) {
|
||||
let v = params(Some(&f));
|
||||
let back = match parse(&v) {
|
||||
Ok(Change::Fill(f)) => f,
|
||||
Ok(Change::Solid(c, t)) => Fill::Solid { color: c, transparency: t.unwrap_or(0.0) },
|
||||
_ => panic!("{v}"),
|
||||
};
|
||||
assert_eq!(back, f, "{v}");
|
||||
}
|
||||
assert!(matches!(parse(¶ms(None)), Ok(Change::None)));
|
||||
}
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
fn st(pos: f32, c: u8, transparency: f32) -> GradStop {
|
||||
GradStop { pos, color: Rgb(c, c, c), transparency }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_are_added_in_the_gradients_colour_moved_in_order_and_kept_two_to_ten() {
|
||||
let mut g = vec![st(0.0, 0, 0.0), st(100.0, 200, 50.0)];
|
||||
assert_eq!(stops::add(&mut g, 25.0), Some(1));
|
||||
assert_eq!((g[1].color, g[1].transparency), (Rgb(50, 50, 50), 12.5));
|
||||
assert_eq!(stops::add(&mut g, f32::NAN), Some(2), "a junk position goes in the middle");
|
||||
assert_eq!(stops::move_to(&mut g, 1, 300.0), 3, "past the end: last");
|
||||
assert_eq!(g.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0, 100.0]);
|
||||
assert_eq!(stops::move_to(&mut g, 3, -1.0), 1, "after the stop already at 0%");
|
||||
assert!(stops::remove(&mut g, 0) && stops::remove(&mut g, 0));
|
||||
assert!(!stops::remove(&mut g, 0), "two stops stay");
|
||||
assert!(!stops::remove(&mut vec![st(0.0, 0, 0.0); 3], 7));
|
||||
while stops::add(&mut g, 10.0).is_some() {}
|
||||
assert_eq!(g.len(), MAX_STOPS);
|
||||
assert_eq!(stops::at(&g, 200.0).0, g[g.len() - 1].color);
|
||||
assert_eq!(stops::at(&[], 5.0), (Rgb::BLACK, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -615,6 +615,57 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
/// is marked deleted as for any selection).
|
||||
pub(crate) fn clear_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
if s.doc.settings.track_changes {
|
||||
return None;
|
||||
}
|
||||
let cr = s.cell_range()?.clone();
|
||||
let t = s.doc.table(cr.story, &cr.table)?;
|
||||
let prefixes: Vec<Vec<u32>> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect();
|
||||
let spans = text_spans(s, cr.story, &prefixes);
|
||||
Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans))
|
||||
}
|
||||
|
||||
/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the
|
||||
/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom
|
||||
/// up so earlier spans stay valid; the caret goes to the start of the first cell.
|
||||
fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult {
|
||||
// A cell's first block (its first paragraph may have been in a nested table that goes).
|
||||
let first_block = |p: &Pos| {
|
||||
let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec();
|
||||
v.push(0);
|
||||
Pos { story: p.story, path: Path(v), off: 0 }
|
||||
};
|
||||
for (a, b) in spans {
|
||||
super::controls::check_delete(s, a, b)?;
|
||||
}
|
||||
for (a, b) in spans.iter().rev() {
|
||||
let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue };
|
||||
s.doc.delete_range(a, b)?;
|
||||
// Now the cell's only block, unless it holds a table nested deeper than the paragraph
|
||||
// walk (`delete_range` leaves those).
|
||||
let first = first_block(a);
|
||||
if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1)
|
||||
&& let Ok(p) = s.doc.para_mut(first.story, &first.path)
|
||||
{
|
||||
(p.props, p.mark) = last;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
|
||||
@@ -724,13 +724,21 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result<f64, CmdError> {
|
||||
}
|
||||
"ABS" => Ok(one(name, args, here)?.abs()),
|
||||
"INT" => Ok(one(name, args, here)?.floor()),
|
||||
"SIGN" => Ok(one(name, args, here)?.signum()),
|
||||
"SIGN" => {
|
||||
let x = one(name, args, here)?;
|
||||
Ok(if x == 0.0 { 0.0 } else { x.signum() })
|
||||
}
|
||||
"ROUND" => match args {
|
||||
[a, b] => {
|
||||
let x = scalar(eval(a, here)?)?;
|
||||
let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc();
|
||||
let scale = 10f64.powi(digits as i32);
|
||||
finite((x * scale).round() / scale)
|
||||
let scaled = x * scale;
|
||||
if !scaled.is_finite() {
|
||||
// Too large to have a fractional part at this precision.
|
||||
return finite(x);
|
||||
}
|
||||
finite(scaled.round() / scale)
|
||||
}
|
||||
_ => Err(CmdError::Failed("ROUND takes two arguments".into())),
|
||||
},
|
||||
@@ -769,6 +777,16 @@ mod tests {
|
||||
evaluate(t, r, c, f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_of_zero_is_zero() {
|
||||
let t = table(&[&["0", "5", "-5"]]);
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(0)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(-0)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 0, 1, "=SIGN(A1)"), Ok(0.0), "a cell holding 0");
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(5)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(-5)"), Ok(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_references_count_from_one_and_from_a() {
|
||||
assert_eq!(cell_ref("A1"), Some((0, 0)));
|
||||
@@ -807,6 +825,14 @@ mod tests {
|
||||
assert!(err.contains("MEDIAN"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_large_values_return_input() {
|
||||
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
|
||||
let big = format!("1{}", "0".repeat(300));
|
||||
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},15)")), Ok(1e300));
|
||||
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},2)")), Ok(1e300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_and_references_and_operators() {
|
||||
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
|
||||
|
||||
@@ -199,6 +199,30 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize,
|
||||
.max_by_key(|g| g.table.0.len())
|
||||
}
|
||||
|
||||
/// The move and resize handles of table `table` of `story` on this page, as Word shows them
|
||||
/// while the caret is in the table: the move handle where it starts, the resize handle where it
|
||||
/// ends.
|
||||
pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option<Rect>, Option<Rect>) {
|
||||
let rows = doc.table(story, table).map_or(0, |t| t.rows.len());
|
||||
let has_row = |p: usize, r: usize| {
|
||||
l.pages.get(p).is_some_and(|pg| {
|
||||
pg.items.iter().any(|it| matches!(it, Placed::Cell { table: t, story: st, row, .. } if *st == story && t == table && *row == r))
|
||||
})
|
||||
};
|
||||
// A repeated header row, or a row running on to the next page, is drawn on more than one
|
||||
// page: the handles go where the table starts and where it ends (its pages are consecutive).
|
||||
let first = has_row(page, 0) && !page.checked_sub(1).is_some_and(|p| has_row(p, 0));
|
||||
let last = rows.checked_sub(1).is_some_and(|r| has_row(page, r) && !page.checked_add(1).is_some_and(|p| has_row(p, r)));
|
||||
if !first && !last {
|
||||
return (None, None);
|
||||
}
|
||||
// The table's geometry on this page that the pointer handles are drawn and grabbed from.
|
||||
let Some(g) = table_resize::tables_on(l, doc, page).into_iter().find(|g| g.story == story && g.table == *table) else {
|
||||
return (None, None);
|
||||
};
|
||||
(first.then(|| handle(&g)), last.then(|| g.handle()))
|
||||
}
|
||||
|
||||
/// The insert button a target is, if it's one.
|
||||
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
|
||||
let g = &t.geom;
|
||||
@@ -374,7 +398,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// 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;
|
||||
let mut g = row.first_grid();
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if g == line {
|
||||
return Some((r, c, false));
|
||||
@@ -425,7 +449,7 @@ fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
pub 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),
|
||||
@@ -1027,4 +1051,35 @@ mod tests {
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
|
||||
}
|
||||
|
||||
/// #2038: a table dragged to the right floats and the empty paragraph after it moves up beside
|
||||
/// it. As in Word, a click anywhere below the table then goes to that paragraph, not to a cell of the last row; a click in a cell still lands there.
|
||||
#[test]
|
||||
fn a_click_below_a_floating_table_goes_to_the_text_not_its_cells() {
|
||||
let mut s = table_session(3, 3);
|
||||
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 + 60.0, "toY": hy + 10.0})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_some());
|
||||
let after = Path(vec![table_path(&s).0[0] + 1]);
|
||||
assert!(s.doc.block(StoryRef::Body, &after).is_some_and(|b| b.as_table().is_none()), "a paragraph follows the table");
|
||||
let (first, last) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 2));
|
||||
let (mid, bottom, margin) = ((first.x + last.right()) / 2.0, last.bottom(), s.layout().pages[0].body.x + 5.0);
|
||||
let at = |s: &mut Session, x: f32, y: f32| {
|
||||
s.run("caret.set", &json!({"page": 0, "x": x, "y": y})).unwrap();
|
||||
s.sel.focus.path.clone()
|
||||
};
|
||||
for (name, x, y) in [
|
||||
("just below the middle", mid, bottom + 12.0),
|
||||
("far below the middle", mid, bottom + 200.0),
|
||||
("below, at the margin", margin, bottom + 12.0),
|
||||
] {
|
||||
assert_eq!(at(&mut s, x, y), after, "a click {name}");
|
||||
}
|
||||
let b2 = cell_rect(&mut s, 1, 1);
|
||||
assert_eq!(at(&mut s, b2.x + b2.w / 2.0, mid_y(b2)), cell_pos(&s, 1, 1).path, "in B2");
|
||||
assert_eq!(at(&mut s, last.x + last.w / 2.0, last.bottom() - 1.0), cell_pos(&s, 2, 2).path, "low in C3");
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert_eq!(s.sel.focus.path, after, "Ctrl+End");
|
||||
}
|
||||
}
|
||||
@@ -1016,4 +1016,130 @@ mod tests {
|
||||
assert_eq!(first_table(&s).grid.len(), 3, "no new grid line");
|
||||
assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows");
|
||||
}
|
||||
|
||||
/// Selects the cell at (row, cell) one of the ways Word does: Select Cell, the left-edge
|
||||
/// arrow, or a triple-click on its text.
|
||||
fn select_one_cell(s: &mut Session, how: &str, r: usize, c: usize) {
|
||||
let b = cell_rect(s, r, c);
|
||||
let y = b.y + b.h / 2.0;
|
||||
match how {
|
||||
"arrow" => {
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + 4.5, "y": y})).unwrap();
|
||||
}
|
||||
"triple" => {
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("select.paragraph", &json!({"cell": true})).unwrap();
|
||||
}
|
||||
_ => {
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("table.selectCell", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each way of selecting one cell makes its right and its left border move in its row
|
||||
/// only; the neighbour on that side gives up or gains the room and the row keeps its width.
|
||||
#[test]
|
||||
fn a_single_selected_cells_borders_move_only_in_its_row() {
|
||||
for how in ["cell", "arrow", "triple"] {
|
||||
for left in [false, true] {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b, c) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
let y = b.y + b.h / 2.0;
|
||||
select_one_cell(&mut s, how, 1, 1);
|
||||
let start = first_table(&s);
|
||||
let x = if left { b.x } else { b.right() };
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": y, "toX": x + 30.0, "toY": y})).unwrap();
|
||||
let (a1, b1, c1) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
if left {
|
||||
assert!((b1.w - (b.w - 30.0)).abs() < 1.0 && (a1.w - (a.w + 30.0)).abs() < 1.0, "{how} left: {a1:?} {b1:?}");
|
||||
assert!((c1.w - c.w).abs() < 0.5, "{how} left");
|
||||
} else {
|
||||
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{how} right: {b1:?} {c1:?}");
|
||||
assert!((a1.w - a.w).abs() < 0.5, "{how} right");
|
||||
}
|
||||
assert!((c1.right() - c.right()).abs() < 0.5, "{how}: the row keeps its width");
|
||||
for r in [0, 2] {
|
||||
assert!(
|
||||
(cell_rect(&mut s, r, 1).x - b.x).abs() < 0.5 && (cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5,
|
||||
"{how}: row {r} unchanged"
|
||||
);
|
||||
}
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "{how}: one undo step");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caret, a part of a cell's text, or another cell's border with a cell selected: the
|
||||
/// whole column moves.
|
||||
#[test]
|
||||
fn without_that_cell_selected_the_whole_column_moves() {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "caret only");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
select_one_cell(&mut s, "arrow", 2, 2);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// All of a cell's text, picked as a range (no cell mark): not a cell selection.
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Wide"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Wide"})).unwrap();
|
||||
let (anchor, focus) = (serde_json::to_value(&s.sel.anchor).unwrap(), serde_json::to_value(&s.sel.focus).unwrap());
|
||||
s.run("select.range", &json!({"anchor": anchor, "focus": focus})).unwrap();
|
||||
assert!(s.cell_range().is_none());
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "all of a cell's text selected");
|
||||
}
|
||||
|
||||
/// The jagged row a single cell's drag leaves is saved to .docx and read back the same.
|
||||
#[test]
|
||||
fn a_jagged_row_survives_a_docx_round_trip() {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
select_one_cell(&mut s, "arrow", 1, 1);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
let t = first_table(&s);
|
||||
let bytes = crate::io::save_bytes("jagged.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("jagged.docx", &bytes).unwrap();
|
||||
let back = doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap();
|
||||
let spans = |t: &Table| t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect::<Vec<_>>()).collect::<Vec<_>>();
|
||||
assert_eq!(spans(&back), spans(&t));
|
||||
let mut s2 = Session::new(doc);
|
||||
for c in 0..spans(&t)[1].len() {
|
||||
assert!((cell_rect(&mut s2, 1, c).w - cell_rect(&mut s, 1, c).w).abs() < 1.0, "cell {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a cell of two paragraphs, a triple-click on the last one selects the cell (its border
|
||||
/// then moves in its row only); on the first one it selects text, and the whole column moves.
|
||||
#[test]
|
||||
fn triple_click_selects_a_cell_only_on_its_last_paragraph() {
|
||||
for last in [true, false] {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "One"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Two"})).unwrap();
|
||||
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
assert!(s.cell_range().is_none(), "the ribbon's Select Paragraph never selects the cell");
|
||||
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
|
||||
s.run("select.paragraph", &json!({"cell": true})).unwrap();
|
||||
assert_eq!(s.cell_range().is_some(), last);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
let moved = (cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0;
|
||||
assert_eq!(moved, !last, "last paragraph {last}: the column moves only without a cell selection");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,14 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
// Bands of one row and one column unless the style it's based on sets them: Word, like us,
|
||||
// reads a style with no band size as having no bands (#3415).
|
||||
let base = st.based_on.as_deref().and_then(|b| s.doc.styles.table_style(b)).map(|t| t.parts).unwrap_or_default();
|
||||
if base.band_size.is_none() || base.col_band_size.is_none() {
|
||||
let parts = st.table.get_or_insert_with(TableStyleParts::default);
|
||||
parts.band_size = base.band_size.is_none().then_some(1);
|
||||
parts.col_band_size = base.col_band_size.is_none().then_some(1);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn is_network_path(p: &str) -> bool {
|
||||
pub(crate) fn is_network_path(p: &str) -> bool {
|
||||
let b = p.as_bytes();
|
||||
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"),
|
||||
CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"),
|
||||
CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"),
|
||||
// A tab character even in a table cell, where Tab moves to the next cell (#3137); ⌥Tab on macOS.
|
||||
CommandSpec::new("text.insertTab", "Tab Character", "Editing", |s, _| ins_char(s, '\t'))
|
||||
.key(if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }),
|
||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||
@@ -133,14 +136,20 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph.
|
||||
/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph
|
||||
/// (Word triggers on the paragraph's first characters, whatever text already follows the caret,
|
||||
/// not only when the trigger is the paragraph's entire content — #3352).
|
||||
fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
|
||||
if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() {
|
||||
// A style-linked list (a heading-linked Multilevel List preset, #1793) numbers the paragraph
|
||||
// without `props.numbering` itself being set: resolve it, so autoformat doesn't paper over an
|
||||
// already-numbered heading with a fresh bullet.
|
||||
if s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) {
|
||||
return Ok(false);
|
||||
}
|
||||
let kind = match para.text.as_str() {
|
||||
let Some(prefix) = para.text.get(..f.off) else { return Ok(false) };
|
||||
let kind = match prefix {
|
||||
"* " => ListKind::Bullet,
|
||||
"- " => ListKind::BulletChar('-'),
|
||||
// Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows).
|
||||
@@ -168,8 +177,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let len = para.len();
|
||||
para.delete(0, len)?;
|
||||
para.delete(0, f.off)?;
|
||||
para.props.numbering = Some(NumRef { num, level: 0 });
|
||||
// A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1).
|
||||
if para.props.style.as_deref().is_none_or(|st| st == "Normal") {
|
||||
|
||||
@@ -489,17 +489,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
// As in Word, the new document is attached to the template it came from (#2852).
|
||||
doc.settings.attached_template = Some(path.to_string());
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"path": string}"#)
|
||||
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") };
|
||||
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
|
||||
let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") };
|
||||
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path}))
|
||||
})
|
||||
@@ -629,14 +632,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"header"?: pt, "footer"?: pt}"#),
|
||||
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
|
||||
let story = s.sel.focus.story;
|
||||
let current = {
|
||||
let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id));
|
||||
table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4])
|
||||
};
|
||||
let m = [
|
||||
p::f32(v, "top").unwrap_or(0.0),
|
||||
p::f32(v, "left").unwrap_or(5.4),
|
||||
p::f32(v, "bottom").unwrap_or(0.0),
|
||||
p::f32(v, "right").unwrap_or(5.4),
|
||||
p::f32(v, "top").unwrap_or(current[0]),
|
||||
p::f32(v, "left").unwrap_or(current[1]),
|
||||
p::f32(v, "bottom").unwrap_or(current[2]),
|
||||
p::f32(v, "right").unwrap_or(current[3]),
|
||||
]
|
||||
.map(|x| x.clamp(0.0, 144.0));
|
||||
let story = s.sel.focus.story;
|
||||
s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m);
|
||||
for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) {
|
||||
s.doc.para_mut(story, &path)?.touch();
|
||||
@@ -699,7 +707,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default language)}"#,
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
@@ -752,7 +760,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default language; `detect` is the per-user preference.
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
/// Nothing at all sets US English, as before.
|
||||
fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lang = match p::str(v, "lang") {
|
||||
@@ -784,14 +793,18 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if lang.is_none() && no_proof.is_none() {
|
||||
return Ok(json!({"detect": s.prefs.detect_language}));
|
||||
}
|
||||
super::format::apply(s, &|c| {
|
||||
let result = super::format::apply(s, &|c| {
|
||||
if let Some(l) = &lang {
|
||||
c.lang = Some(l.clone());
|
||||
}
|
||||
if let Some(np) = no_proof {
|
||||
c.no_proof = Some(np);
|
||||
}
|
||||
})
|
||||
})?;
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
s.prefs.default_language = lang;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn macros(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -892,8 +905,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
|
||||
}
|
||||
// A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the
|
||||
// same way (#835).
|
||||
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()) == wordcraft_proof::ProofLang::En);
|
||||
// same way (#835); every English (en-GB, en-CA…) counts (#1281).
|
||||
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()).is_english());
|
||||
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Personal templates (#2852): the `.dotx`/`.dotm` files in the user templates folder, which
|
||||
//! File › New lists next to the built-in templates.
|
||||
//!
|
||||
//! The folder is the user's own (File › Options › Advanced › File locations, else the app's
|
||||
//! `Templates` folder, which the front end passes as `dir`). As in Word, the templates at its top
|
||||
//! level are listed, and each folder directly inside it is a category of the templates it holds;
|
||||
//! deeper folders are not looked into. A template is named by its file name, as in Word's
|
||||
//! gallery; its title (the core properties' `dc:title`), when it has one, comes along for search
|
||||
//! (a template saved from a document often keeps that document's title, so it can't be the name).
|
||||
//!
|
||||
//! Everything read here comes from the disk, so it is bounded: the number of entries looked at,
|
||||
//! of categories and of templates listed, the size of a template whose title is read and of the
|
||||
//! part it is read from. A file that can't be read is listed by its name or skipped, never an
|
||||
//! error. A network folder (`\\host\share`) is refused, as attached templates are
|
||||
//! ([`super::templates`]), and the automation read root applies to the folder and each file.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session};
|
||||
|
||||
/// Most templates one listing returns.
|
||||
pub const MAX_TEMPLATES: usize = 500;
|
||||
/// Most category folders looked into.
|
||||
pub const MAX_CATEGORIES: usize = 64;
|
||||
/// Most directory entries looked at in one folder.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
/// Longest folder path taken, in bytes.
|
||||
const MAX_DIR: usize = 32 * 1024;
|
||||
/// Largest template whose title is read (bigger ones are listed by file name).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_TITLE_FILE: u64 = 64 << 20;
|
||||
/// Most bytes of the core properties part read for the title.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CORE_XML: u64 = 64 << 10;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.listTemplates", "Personal Templates", "File › New", list)
|
||||
.params(r#"{"dir"?: string (the user templates folder; default: the one saved in File › Options)} → {"dir", "templates": [{"label" (the file name without its extension), "title": string|null (dc:title), "path", "category" ("" at the top level, else the subfolder's name), "modified": ISO-8601|null}] (.dotx and .dotm, at most 500, by category then label), "truncated": bool}; a network folder is refused"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
/// One template in the folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PersonalTemplate {
|
||||
/// Its file name without the extension.
|
||||
pub label: String,
|
||||
/// The title in its core properties, if any.
|
||||
pub title: Option<String>,
|
||||
pub path: String,
|
||||
/// The subfolder it is in; empty at the top level.
|
||||
pub category: String,
|
||||
/// When the file last changed (ISO-8601, UTC), if the system says.
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
/// What [`list_templates`] found.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Listing {
|
||||
pub templates: Vec<PersonalTemplate>,
|
||||
/// Some were left out (a cap was reached).
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let dir = match v.get("dir") {
|
||||
None | Some(Value::Null) => {
|
||||
s.prefs.user_templates_folder.clone().ok_or_else(|| CmdError::Params("give `dir`: no user templates folder is saved".into()))?
|
||||
}
|
||||
Some(Value::String(d)) => d.clone(),
|
||||
Some(_) => return Err(CmdError::Params("`dir` must be a folder path".into())),
|
||||
};
|
||||
let found = list_templates(&dir)?;
|
||||
Ok(json!({
|
||||
"dir": dir,
|
||||
"templates": found.templates.iter().map(|t| json!({"label": t.label, "title": t.title, "path": t.path, "category": t.category, "modified": t.modified})).collect::<Vec<_>>(),
|
||||
"truncated": found.truncated,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check that `dir` may be listed: a full local path, not too long, not on another machine.
|
||||
pub fn check_dir(dir: &str) -> Result<(), CmdError> {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return Err(CmdError::Params("`dir` is empty".into()));
|
||||
}
|
||||
if d.len() > MAX_DIR || d.chars().any(char::is_control) {
|
||||
return Err(CmdError::Params("`dir` isn't a folder path".into()));
|
||||
}
|
||||
// A folder on another machine: listing it would make Windows authenticate to that host.
|
||||
if super::templates::is_network_path(d) {
|
||||
return Err(CmdError::Params("templates aren't listed from a network folder".into()));
|
||||
}
|
||||
if !std::path::Path::new(d).is_absolute() {
|
||||
return Err(CmdError::Params(format!("`dir`: {d:?} isn't a full path")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The personal templates in `dir` (see the module docs).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
|
||||
use std::path::Path;
|
||||
check_dir(dir)?;
|
||||
let dir = dir.trim();
|
||||
crate::file_access::check_read(dir).map_err(CmdError::Failed)?;
|
||||
let root = Path::new(dir);
|
||||
if !root.is_dir() {
|
||||
return Err(CmdError::Failed(format!("no folder at {dir}")));
|
||||
}
|
||||
let mut out = Listing::default();
|
||||
let (files, folders, cut) = entries(root);
|
||||
out.truncated |= cut;
|
||||
let mut found: Vec<(String, crate::io::DirEntry)> = files.into_iter().map(|f| (String::new(), f)).collect();
|
||||
let mut folders = folders;
|
||||
folders.sort();
|
||||
if folders.len() > MAX_CATEGORIES {
|
||||
folders.truncate(MAX_CATEGORIES);
|
||||
out.truncated = true;
|
||||
}
|
||||
for folder in folders {
|
||||
let Some(category) = folder.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue };
|
||||
if crate::file_access::check_read(&folder.to_string_lossy()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let (files, _, cut) = entries(&folder);
|
||||
out.truncated |= cut;
|
||||
found.extend(files.into_iter().map(|f| (category.clone(), f)));
|
||||
}
|
||||
// The cap applies to what is listed; titles are read only for those.
|
||||
found.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.1.path.cmp(&b.1.path)));
|
||||
if found.len() > MAX_TEMPLATES {
|
||||
found.truncate(MAX_TEMPLATES);
|
||||
out.truncated = true;
|
||||
}
|
||||
for (category, e) in found {
|
||||
let title = title_of(&e.path);
|
||||
let modified = e.modified.map(super::iso_from_unix_secs);
|
||||
out.templates.push(PersonalTemplate { label: label_of(&e.name), title, path: e.path.to_string_lossy().into_owned(), category, modified });
|
||||
}
|
||||
// Two templates of one category that would show the same name (`Memo.dotx`, `Memo.dotm`)
|
||||
// show their file names instead, so they can be told apart.
|
||||
let key = |t: &PersonalTemplate| (t.category.to_lowercase(), t.label.to_lowercase());
|
||||
let mut seen = std::collections::HashMap::<(String, String), usize>::new();
|
||||
for t in &out.templates {
|
||||
*seen.entry(key(t)).or_default() += 1;
|
||||
}
|
||||
for t in &mut out.templates {
|
||||
if seen.get(&key(t)).is_some_and(|n| *n > 1)
|
||||
&& let Some(name) = std::path::Path::new(&t.path).file_name()
|
||||
{
|
||||
t.label = name.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
// The top level first, then each category; by label within them.
|
||||
out.templates.sort_by_cached_key(|t| (!t.category.is_empty(), t.category.to_lowercase(), t.label.to_lowercase(), t.path.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
|
||||
check_dir(dir)?;
|
||||
Err(CmdError::Failed("templates folders can't be listed on the web".into()))
|
||||
}
|
||||
|
||||
/// The template files and the folders directly in `dir` (hidden ones left out), and whether the
|
||||
/// entry cap cut the listing short. An unreadable folder has none.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::PathBuf>, bool) {
|
||||
let Ok((list, cut)) = crate::io::list_dir(dir, MAX_ENTRIES) else { return (Vec::new(), Vec::new(), false) };
|
||||
let (mut files, mut folders) = (Vec::new(), Vec::new());
|
||||
for e in list {
|
||||
if e.name.starts_with('.') || e.name.starts_with("~$") {
|
||||
continue;
|
||||
}
|
||||
if e.dir {
|
||||
folders.push(e.path);
|
||||
} else if is_template_name(&e.name) {
|
||||
files.push(e);
|
||||
}
|
||||
}
|
||||
(files, folders, cut)
|
||||
}
|
||||
|
||||
/// The name a template shows: its file name without the template extension, every one of them
|
||||
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
|
||||
/// nothing would be left.
|
||||
fn label_of(name: &str) -> String {
|
||||
let mut label = name;
|
||||
while is_template_name(label) {
|
||||
let Some(stem) = label.get(..label.len().saturating_sub(5)) else { break };
|
||||
label = stem;
|
||||
}
|
||||
let label = label.trim_end();
|
||||
if label.is_empty() { name.to_string() } else { label.to_string() }
|
||||
}
|
||||
|
||||
/// A Word template by its name: `.dotx` or `.dotm`, in any case.
|
||||
pub fn is_template_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
(lower.ends_with(".dotx") || lower.ends_with(".dotm")) && lower.len() > 5
|
||||
}
|
||||
|
||||
/// The template's title from its core properties, read from the package's directory and at most
|
||||
/// [`MAX_CORE_XML`] bytes of the part; `None` for anything else.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn title_of(path: &std::path::Path) -> Option<String> {
|
||||
let title = crate::io::package_title(path, MAX_TITLE_FILE, MAX_CORE_XML)?;
|
||||
// One line of a sensible length for a gallery caption.
|
||||
let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).take(200).collect();
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| title.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn scratch(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wc-personal-templates-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A real template written by the engine, with `title` as its core title (empty: none).
|
||||
fn template(path: &std::path::Path, text: &str, title: &str) {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": text})).unwrap();
|
||||
s.doc.core.title = title.into();
|
||||
crate::io::save_path(path, &s.doc).unwrap();
|
||||
}
|
||||
|
||||
fn labels(r: &Value) -> Vec<(String, String)> {
|
||||
r["templates"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| (t["category"].as_str().unwrap().to_string(), t["label"].as_str().unwrap().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_dotx_and_dotm_with_subfolders_as_categories() {
|
||||
let d = scratch("list");
|
||||
template(&d.join("Weekly report.dotx"), "Report body", "");
|
||||
template(&d.join("letterhead.dotx"), "Letter body", "Company letterhead");
|
||||
template(&d.join("Macros.dotm"), "Macro body", "");
|
||||
template(&d.join("Not a template.docx"), "Doc body", "");
|
||||
std::fs::write(d.join("notes.txt"), "x").unwrap();
|
||||
std::fs::write(d.join("Broken.dotx"), b"this is no zip").unwrap();
|
||||
std::fs::write(d.join(".hidden.dotx"), b"x").unwrap();
|
||||
std::fs::create_dir_all(d.join("Legal/Deeper")).unwrap();
|
||||
template(&d.join("Legal/NDA.DOTX"), "NDA body", "");
|
||||
template(&d.join("Legal/Deeper/Too deep.dotx"), "x", "");
|
||||
std::fs::create_dir_all(d.join("Empty")).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("file.listTemplates", &json!({"dir": d.to_string_lossy()})).unwrap();
|
||||
assert_eq!(
|
||||
labels(&r),
|
||||
[
|
||||
("".into(), "Broken".into()),
|
||||
("".into(), "letterhead".into()),
|
||||
("".into(), "Macros".into()),
|
||||
("".into(), "Weekly report".into()),
|
||||
("Legal".into(), "NDA".into()),
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
assert_eq!(r["truncated"], json!(false));
|
||||
let first = &r["templates"][1];
|
||||
assert_eq!(first["title"], json!("Company letterhead"));
|
||||
assert_eq!(r["templates"][0]["title"], json!(null), "a broken file has no title");
|
||||
assert!(first["path"].as_str().unwrap().ends_with("letterhead.dotx"));
|
||||
assert!(first["modified"].as_str().is_some_and(|m| m.ends_with('Z')));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_network_relative_and_missing_folders() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for dir in [r"\\host\share\Templates", "//host/share/Templates", "relative/Templates", "", " "] {
|
||||
assert!(matches!(s.run("file.listTemplates", &json!({"dir": dir})), Err(CmdError::Params(_))), "{dir:?}");
|
||||
}
|
||||
let missing = std::env::temp_dir().join(format!("wc-no-such-templates-{}", std::process::id()));
|
||||
assert!(s.run("file.listTemplates", &json!({"dir": missing.to_string_lossy()})).is_err());
|
||||
// Without `dir` the saved folder is used; none saved is an error.
|
||||
assert!(matches!(s.run("file.listTemplates", &json!({})), Err(CmdError::Params(_))));
|
||||
for junk in [json!(null), json!({"dir": 5}), json!({"dir": []}), json!({"dir": "x".repeat(40_000)}), json!({"dir": "/a\u{0}b"})] {
|
||||
let _ = s.run("file.listTemplates", &junk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_folder_is_listed_without_dir() {
|
||||
let d = scratch("saved");
|
||||
template(&d.join("Saved.dotx"), "x", "");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
|
||||
let r = s.run("file.listTemplates", &json!({})).unwrap();
|
||||
assert_eq!(labels(&r), [("".to_string(), "Saved".to_string())]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_is_capped() {
|
||||
let d = scratch("cap");
|
||||
for i in 0..MAX_TEMPLATES + 5 {
|
||||
std::fs::write(d.join(format!("t{i:04}.dotx")), b"").unwrap();
|
||||
}
|
||||
let found = list_templates(&d.to_string_lossy()).unwrap();
|
||||
assert_eq!(found.templates.len(), MAX_TEMPLATES);
|
||||
assert!(found.truncated);
|
||||
assert_eq!(found.templates.first().map(|t| t.label.as_str()), Some("t0000"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A document made from a personal template is untitled, unsaved and attached to the
|
||||
/// template, as in Word.
|
||||
#[test]
|
||||
fn new_from_template_is_untitled_and_attached() {
|
||||
let d = scratch("new");
|
||||
let path = d.join("Memo.dotx");
|
||||
template(&path, "Memo text", "");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.path = Some(d.join("Earlier.docx"));
|
||||
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.path, None);
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo text"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_templates_folder_option_is_saved_and_checked() {
|
||||
let d = scratch("option");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for bad in [json!(r"\\host\share"), json!("//host/share"), json!("relative/x"), json!(7)] {
|
||||
assert!(s.run("file.options", &json!({"userTemplatesFolder": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.run("file.options", &json!({"userTemplatesFolder": d.join("missing").to_string_lossy()})).is_err());
|
||||
assert_eq!(s.prefs.user_templates_folder, None, "a refused folder changes nothing");
|
||||
let r = s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["userTemplatesFolder"], json!(d.to_string_lossy()));
|
||||
assert_eq!(serde_json::to_value(&s.prefs).unwrap()["userTemplatesFolder"], json!(d.to_string_lossy()));
|
||||
s.run("file.options", &json!({"userTemplatesFolder": "auto"})).unwrap();
|
||||
assert_eq!(s.prefs.user_templates_folder, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// .dotx and .dotm templates show their names the same way, without the extension; two that
|
||||
/// would look alike in one category show their file names.
|
||||
#[test]
|
||||
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
|
||||
let d = scratch("labels");
|
||||
template(&d.join("Service contract.dotm"), "a", "");
|
||||
template(&d.join("Old contract.dotm.dotx"), "b", "");
|
||||
template(&d.join("Memo.dotx"), "c", "");
|
||||
template(&d.join("Memo.DOTM"), "d", "");
|
||||
std::fs::create_dir_all(d.join("Other")).unwrap();
|
||||
template(&d.join("Other/Memo.dotx"), "e", "");
|
||||
let found = list_templates(&d.to_string_lossy()).unwrap();
|
||||
let got: Vec<(&str, &str)> = found.templates.iter().map(|t| (t.category.as_str(), t.label.as_str())).collect();
|
||||
assert_eq!(got, [("", "Memo.DOTM"), ("", "Memo.dotx"), ("", "Old contract"), ("", "Service contract"), ("Other", "Memo")]);
|
||||
assert_eq!(label_of(".dotx.dotm"), ".dotx", "never empty");
|
||||
assert_eq!(label_of(" .dotx"), " .dotx", "nothing left: the whole name");
|
||||
assert_eq!(label_of("é.dotx"), "é");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Save as Template keeps a .dotm (any case) and makes anything else a .dotx.
|
||||
#[test]
|
||||
fn save_template_keeps_a_template_extension() {
|
||||
let d = scratch("save");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for (asked, saved) in [("A.dotm", "A.dotm"), ("B.DOTX", "B.DOTX"), ("C", "C.dotx"), ("D.docx", "D.docx.dotx")] {
|
||||
let r = s.run("file.saveTemplate", &json!({"path": d.join(asked).to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["path"], json!(d.join(saved).to_string_lossy()));
|
||||
assert!(d.join(saved).is_file(), "{saved}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_names() {
|
||||
assert!(is_template_name("a.dotx") && is_template_name("B.DOTM"));
|
||||
assert!(!is_template_name(".dotx") && !is_template_name("a.docx") && !is_template_name("a.dot"));
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
|
||||
// Focus and Full Screen View (#1678) fill the screen; turning one on turns the other off.
|
||||
// Full Screen View has no key of its own: Customize Keyboard binds one.
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| {
|
||||
let r = toggle(s, v, |x| &mut x.focus_mode);
|
||||
s.view.full_screen_view &= !s.view.focus_mode;
|
||||
r
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.fullScreenView", "Full Screen View", "View › Immersive", |s, v| {
|
||||
let r = toggle(s, v, |x| &mut x.full_screen_view);
|
||||
s.view.focus_mode &= !s.view.full_screen_view;
|
||||
r
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||||
// The drawing grid over the page (screen only; never printed or exported).
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
|
||||
@@ -101,6 +116,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.formatShapePane", "Format Shape Pane", "Shape Format › Shape Styles", |s, v| {
|
||||
toggle(s, v, |x| &mut x.format_shape_pane)
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "newWindow"}));
|
||||
sel_result(s)
|
||||
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user