mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 06:16:58 -06:00
Merge branch 'w1' into w2
# Conflicts: # ROADMAP.md
This commit is contained in:
@@ -0,0 +1 @@
|
||||
craft-i18n.rev text eol=lf
|
||||
+13
-47
@@ -1,8 +1,7 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
|
||||
# only the next contributor's local run discovers.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -12,51 +11,18 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
|
||||
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
|
||||
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "27 4 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "27 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -29,4 +28,4 @@ jobs:
|
||||
# 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/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
+3
-15
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
|
||||
| `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) |
|
||||
@@ -67,18 +69,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/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 |
|
||||
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
|
||||
|
||||
Generated
+1978
-368
File diff suppressed because it is too large
Load Diff
+40
-10
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -52,13 +52,18 @@ harfrust = "0.13"
|
||||
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
|
||||
# (brotli-decompressor, flate2) are already in the tree.
|
||||
wuff = "0.2.9"
|
||||
# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703).
|
||||
# The same crate and version wuff already decodes it with (no new code in the build): pure Rust,
|
||||
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.8"
|
||||
miniz_oxide = "0.9"
|
||||
cfb = "0.15"
|
||||
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
|
||||
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
|
||||
@@ -70,17 +75,22 @@ aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
|
||||
# dependency of other crates.
|
||||
libc = "0.2"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
# A monotonic clock that works on native and in the browser (`performance.now()`).
|
||||
web-time = "1.1"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -91,11 +101,16 @@ todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
# for every test binary and dependency would multiply the target dir size.
|
||||
# The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -105,4 +120,19 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
|
||||
the bundled egui font icons have the separate notice below.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
e8b4dde5137fe41823326b55018c31302218d3a4. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
|
||||
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
|
||||
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
|
||||
the exact copyright and permission notice from the pinned egui dependency follows.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
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.
|
||||
|
||||
@@ -221,13 +221,16 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
| Build | Installer (all users) | Installer (just me) | Portable |
|
||||
|---|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
|
||||
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
|
||||
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
|
||||
in the Start menu. Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
|
||||
+106
-2
File diff suppressed because one or more lines are too long
@@ -184,6 +184,8 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
|
||||
/// The command also checks whether the open document is protected: this name determines which
|
||||
/// named editing exceptions the session can use, so the flag must not bypass that check.
|
||||
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
|
||||
}
|
||||
@@ -381,6 +383,16 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
|
||||
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
|
||||
}
|
||||
|
||||
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
|
||||
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
|
||||
/// would otherwise retry one on every call and never say why nothing connects.
|
||||
fn valid_connect_addr(addr: &str) -> bool {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else { return false };
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
/// Run the subcommand named by `args` (the command line after the program name). Files named
|
||||
/// there are opened by their real names, which may not be valid Unicode.
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
@@ -588,11 +600,27 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
|
||||
}
|
||||
Some(addr) => {
|
||||
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
// A malformed address must fail at startup, not on every tool call the lazy
|
||||
// remote would retry it with.
|
||||
if !valid_connect_addr(&addr) {
|
||||
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
|
||||
}
|
||||
match wordcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(remote) => {
|
||||
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
// The app may start after the client does: serve anyway and connect on the
|
||||
// first call, or the client marks the server failed for its whole session
|
||||
// (#4078).
|
||||
Err(e) => {
|
||||
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
Box::new(wordcraft_mcp::Remote::lazy(&addr))
|
||||
}
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
None => {
|
||||
let mut headless = wordcraft_mcp::Headless::default();
|
||||
@@ -639,14 +667,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| {
|
||||
let r = bridge.handle(&mut s, &mut host, call);
|
||||
if trace {
|
||||
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
|
||||
eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))));
|
||||
eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string())));
|
||||
eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))));
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for a in &host.alerts {
|
||||
eprintln!("Zotero: {a}");
|
||||
eprintln!("Zotero: {}", escape_peer(a));
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
@@ -669,6 +697,27 @@ fn clip(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic
|
||||
/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi
|
||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||
/// terminal or pass for other output.
|
||||
fn escape_peer(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
|
||||
out.extend(c.escape_unicode())
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
@@ -715,6 +764,15 @@ mod tests {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_text_stays_on_one_visible_line() {
|
||||
let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é";
|
||||
let shown = escape_peer(alert);
|
||||
assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é");
|
||||
assert!(!shown.chars().any(|c| c.is_control()));
|
||||
assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
|
||||
@@ -813,6 +871,34 @@ mod tests {
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// A malformed address fails at startup, not on every retried call.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.author.clone();
|
||||
assert!(set_author(&mut s, "Claude").is_err());
|
||||
assert_eq!(s.author, before);
|
||||
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_cannot_claim_a_named_exception_after_protection() {
|
||||
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(s.doc.perm_ranges().len(), 1);
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Named"})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
|
||||
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1135,7 +1221,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
|
||||
/// (as in `tests/non_utf8_args.rs`).
|
||||
/// (as in `tests/it/non_utf8_args.rs`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
//! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI
|
||||
//! quietly with exit 0, never with a panic (#508).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -0,0 +1,9 @@
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert_fields;
|
||||
mod convert_region;
|
||||
mod help;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod non_utf8_args;
|
||||
mod picture_then_typing;
|
||||
mod text_tracked;
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
|
||||
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
|
||||
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
|
||||
#![cfg(all(unix, not(target_os = "macos")))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
|
||||
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
pub fn set_author(app: &mut WordApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -15,6 +18,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
last: Option<String>,
|
||||
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
|
||||
/// place while the user keeps the host's.
|
||||
host_author: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(stored: Option<String>) -> Self {
|
||||
Self { last: stored }
|
||||
Self { last: stored, host_author: None }
|
||||
}
|
||||
|
||||
/// The session signs with `host`, a host page's name for the user, but `own` stays the
|
||||
/// stored name. A name the user then chooses in File › Options is stored as usual.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub fn keep_own_author(&mut self, host: &str, own: &str) {
|
||||
if host != own {
|
||||
self.host_author = Some((host.to_string(), own.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON to store when the app's preferences differ from what was last written.
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
@@ -60,6 +78,29 @@ mod tests {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
|
||||
}
|
||||
|
||||
/// A host page's name for the user signs the visit but isn't stored; a name the user then
|
||||
/// chooses is.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut a = app();
|
||||
a.session.author = "Grace Hopper".into();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let stored = sync.changed(&a).unwrap();
|
||||
a.session.author = "rob".into();
|
||||
sync.keep_own_author("rob", "Grace Hopper");
|
||||
a.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let json = sync.changed(&a).expect("the theme changed");
|
||||
assert_ne!(json, stored);
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Grace Hopper");
|
||||
a.session.author = "Rob Hones".into();
|
||||
let json = sync.changed(&a).expect("the user chose a name");
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Rob Hones");
|
||||
}
|
||||
|
||||
/// #2132: dark mode chosen on one visit is there after a reload.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
|
||||
@@ -27,9 +27,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
if bridge.is_none()
|
||||
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
|
||||
{
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -56,12 +62,17 @@ pub fn start() {
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
let sample = query().contains("sample");
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -74,15 +85,31 @@ pub fn start() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let prefs = crate::prefs::PrefsSync::new(stored);
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
// The host's name for the user signs this visit's work, but isn't stored as the
|
||||
// browser's own preference, which a visit outside the host would restore.
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
let own = app.session.author.clone();
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
prefs.keep_own_author(&app.session.author, &own);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -96,6 +123,10 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
@@ -115,22 +146,10 @@ fn stored_prefs() -> Option<String> {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
|
||||
/// then writes changed preferences to browser storage immediately, because a reload or closed
|
||||
/// tab never runs the exit path.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
@@ -149,11 +168,34 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
/// Forwards this frame's raw browser input to the app before widgets run.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
|
||||
/// spawns asynchronous byte reads into the inbox; later passes only draw.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Browser drops are input: take them only in the first real UI pass, never in a hidden
|
||||
// `run_logic` tick that still sees the previous shown frame's RawInput.
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
let ctx = ui.ctx().clone();
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
@@ -420,7 +462,7 @@ fn trigger_save_file_picker(
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -73,13 +73,16 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
pollster = "0.4"
|
||||
pollster = "1"
|
||||
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
|
||||
# in the build through egui-winit, with the same features.
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
# The control server's random key.
|
||||
getrandom = "0.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything.
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
decoded_picture(&mut clipboard)
|
||||
}
|
||||
|
||||
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
|
||||
/// with no size limits (#3623).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
platform::picture()
|
||||
}
|
||||
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -187,6 +201,7 @@ mod platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use clipboard_win::{Clipboard, EnumFormats, raw};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
|
||||
@@ -205,8 +220,31 @@ mod platform {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
|
||||
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
|
||||
/// it holds neither.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
|
||||
};
|
||||
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
|
||||
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
|
||||
}
|
||||
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
|
||||
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
|
||||
}
|
||||
|
||||
/// A clipboard format's bytes, refused past the largest picture a paste takes.
|
||||
fn read(format: u32) -> Result<Vec<u8>, String> {
|
||||
match raw::size(format) {
|
||||
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
|
||||
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
|
||||
None => return Err("the clipboard picture is empty".into()),
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
|
||||
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
|
||||
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
@@ -164,6 +165,16 @@ mod tests {
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
|
||||
#[test]
|
||||
fn the_desktop_entry_does_not_declare_plain_text() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
assert!(!declared.is_empty(), "MimeType= line not found");
|
||||
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
|
||||
@@ -207,8 +207,8 @@ fn on_path(program: &str) -> bool {
|
||||
fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
@@ -258,7 +258,9 @@ mod tests {
|
||||
fn an_instant_cancel_retries_then_falls_back() {
|
||||
let quick = Duration::from_millis(200);
|
||||
let slow_cancel = || async {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
// The test polls this future by hand, with no async runtime, so a blocking sleep
|
||||
// models a slow portal response.
|
||||
std::thread::sleep(Duration::from_millis(250)); // NOSONAR
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
|
||||
@@ -27,39 +27,47 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
/// first frame that knows the monitor has checked it, the control server's key file (removed on
|
||||
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
|
||||
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
|
||||
/// the documents opened from Finder.
|
||||
struct App(
|
||||
WordApp,
|
||||
Option<WindowGeometry>,
|
||||
Option<control_server::KeyFile>,
|
||||
file_dialogs::Launcher,
|
||||
// The host's answer for this frame. `InputState` still describes the last painted frame on
|
||||
// a hidden logic tick.
|
||||
RawWindowState,
|
||||
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
|
||||
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
|
||||
/// on unsaved changes and active printing.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.4, &mut self.0, ctx);
|
||||
apple_events::poll(&self.5, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
let window = self.4;
|
||||
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
|
||||
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
|
||||
// knows the monitor: the off-screen check below takes the pending geometry then.
|
||||
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
let prev = self.0.ui.window;
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
self.0.ui.window = WindowGeometry::track(prev, &window);
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
|
||||
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
|
||||
if let Some(pos) = take_rescue(&mut self.1, &window) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
if let Some(command) = maximize {
|
||||
@@ -85,8 +93,13 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Records this frame's native window state before forwarding the hook, so logic never reads a
|
||||
/// stale inner rectangle from a hidden tick.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Keep this frame's native window state. `logic` must not read the last painted
|
||||
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
|
||||
self.4 = RawWindowState::from_raw(raw);
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -320,7 +333,15 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the command line, installs logging and the panic hook, restores preferences and window
|
||||
/// geometry, opens requested documents or the Start screen, starts the control server when
|
||||
/// asked, and runs the eframe event loop.
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
@@ -429,6 +450,7 @@ fn main() -> eframe::Result {
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
@@ -480,6 +502,7 @@ fn main() -> eframe::Result {
|
||||
Some(restored.unwrap_or_default()),
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
|
||||
/// sends the list through a channel, keeping spooler calls off the UI thread.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
@@ -13,11 +18,11 @@ pub fn discover() -> Discover {
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -46,7 +51,7 @@ pub fn submit() -> Submit {
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
mod non_utf8_args;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
@@ -175,3 +175,9 @@ display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.matstrange]
|
||||
sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
@@ -8,9 +8,15 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
|
||||
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
|
||||
|
||||
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
|
||||
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
|
||||
/// only validates the FAT once it is built, so a small header naming one sector over and over
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
const HEADER_ENTRIES: usize = 109;
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
|
||||
return Ok(());
|
||||
}
|
||||
let sector_len: usize = match u16_at(0x1A) {
|
||||
Some(3) => 512,
|
||||
Some(4) => 4096,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if bytes.len() < sector_len {
|
||||
return Ok(());
|
||||
}
|
||||
// `cfb` counts the sectors after the header sector, the last one possibly partial.
|
||||
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
|
||||
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
|
||||
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
|
||||
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
|
||||
let mut steps = 0usize;
|
||||
while next != END_OF_CHAIN && next != FREE {
|
||||
steps += 1;
|
||||
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
|
||||
// file has sectors must have done one of the two.
|
||||
if steps > num_sectors || next as usize >= num_sectors {
|
||||
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
|
||||
}
|
||||
let at = (next as usize + 1) * sector_len;
|
||||
let entries = sector_len / 4 - 1;
|
||||
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
|
||||
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
|
||||
}
|
||||
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
|
||||
difat.pop();
|
||||
}
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
|
||||
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
|
||||
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn repeating_difat_sample() -> Vec<u8> {
|
||||
const S: usize = 512;
|
||||
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
|
||||
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
|
||||
let mut put = |at: usize, v: &[u8]| {
|
||||
if let Some(dst) = f.get_mut(at..at + v.len()) {
|
||||
dst.copy_from_slice(v);
|
||||
}
|
||||
};
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(0x18, &0x3Eu16.to_le_bytes());
|
||||
put(0x1A, &3u16.to_le_bytes());
|
||||
put(0x1C, &0xFFFEu16.to_le_bytes());
|
||||
put(0x1E, &9u16.to_le_bytes());
|
||||
put(0x20, &6u16.to_le_bytes());
|
||||
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
|
||||
put(0x38, &4096u32.to_le_bytes());
|
||||
put(0x44, &1u32.to_le_bytes());
|
||||
put(0x48, &CHAIN.to_le_bytes());
|
||||
// The 109 header entries are all 0 already; each chain sector's entries too.
|
||||
for d in 1..=CHAIN {
|
||||
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
|
||||
put((d as usize + 2) * S - 4, &next.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
|
||||
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
|
||||
assert!(super::check(b"not a compound file").is_ok());
|
||||
}
|
||||
|
||||
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
|
||||
/// `fat` (the rest free).
|
||||
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
|
||||
let mut f = vec![0u8; 512 * (sectors + 1)];
|
||||
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
|
||||
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
|
||||
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
|
||||
for i in 0..109 {
|
||||
let v = fat.get(i).copied().unwrap_or(u32::MAX);
|
||||
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ impl Counting {
|
||||
out.push(u);
|
||||
}
|
||||
}
|
||||
// Trailing zeros of a group are not a gap: the next group starts a new place.
|
||||
zero_pending = false;
|
||||
match gi {
|
||||
0 => out.extend(self.myriads.get(1)),
|
||||
1 => out.extend(self.myriads.first()),
|
||||
@@ -236,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
|
||||
|
||||
/// The `n`th (1-based) item of a sequence, or decimal past its end.
|
||||
fn nth(set: &[char], n: u32) -> String {
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
|
||||
}
|
||||
|
||||
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
|
||||
@@ -324,6 +326,17 @@ pub(crate) fn chosung(n: u32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chinese_no_zero_after_group_trailing_zeros() {
|
||||
assert_eq!(chinese_counting_thousand(201_000), "二十万一千");
|
||||
assert_eq!(chinese_counting_thousand(120_000), "十二万");
|
||||
assert_eq!(chinese_counting_thousand(100_100), "十万零一百");
|
||||
assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千");
|
||||
assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一");
|
||||
assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万");
|
||||
assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_counting_systems() {
|
||||
let c = |n| chinese_counting(n);
|
||||
|
||||
+290
-12
@@ -10,9 +10,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::graphic::PathSeg;
|
||||
use crate::para::{GroupChild, InlineObject, ShapeKind};
|
||||
use crate::para::{InlineObject, ShapeKind};
|
||||
use crate::wordart::TextWarp;
|
||||
|
||||
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineCap {
|
||||
#[default]
|
||||
Flat,
|
||||
Square,
|
||||
Round,
|
||||
}
|
||||
|
||||
impl LineCap {
|
||||
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sq" => Self::Square,
|
||||
"rnd" => Self::Round,
|
||||
_ => Self::Flat,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the canonical DrawingML spelling used when saving the outline.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Flat => "flat",
|
||||
Self::Square => "sq",
|
||||
Self::Round => "rnd",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cap can be omitted from older-compatible serialized shape properties.
|
||||
pub fn is_flat(&self) -> bool {
|
||||
*self == Self::Flat
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -41,6 +76,9 @@ 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 ends; older saved documents and absent DrawingML caps are flat.
|
||||
#[serde(skip_serializing_if = "LineCap::is_flat")]
|
||||
pub cap: LineCap,
|
||||
/// 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")]
|
||||
@@ -191,9 +229,16 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
|
||||
if at_end { (ux, uy) } else { (-ux, -uy) }
|
||||
}
|
||||
|
||||
/// Re-route the glued connectors among a group's members (they share the group's coordinate
|
||||
/// space). Returns whether any moved.
|
||||
pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
|
||||
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
|
||||
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
|
||||
/// fallback are unchanged.
|
||||
pub fn reroute_group(group: &mut InlineObject) -> bool {
|
||||
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
|
||||
let fin = wordcraft_geom::finite;
|
||||
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
|
||||
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
|
||||
.iter()
|
||||
.filter_map(|c| match &c.obj {
|
||||
@@ -215,9 +260,35 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0));
|
||||
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
|
||||
(Some(s), Some(e)) => (s, e, false),
|
||||
(s, e) => {
|
||||
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Rendering scales the rectangle before spinning it. Undo only the group scale
|
||||
// so route can rebuild an unrotated child frame without moving fallback ends.
|
||||
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
|
||||
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
|
||||
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
|
||||
let spin = float.spin();
|
||||
let s0 = spin.apply(cx, cy, x, y);
|
||||
let e0 = spin.apply(cx, cy, x + cw, y + ch);
|
||||
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
|
||||
}
|
||||
};
|
||||
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
|
||||
continue;
|
||||
}
|
||||
let f = route(s, e);
|
||||
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
|
||||
continue;
|
||||
}
|
||||
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
|
||||
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
|
||||
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
|
||||
continue;
|
||||
}
|
||||
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
|
||||
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
|
||||
continue;
|
||||
@@ -237,7 +308,19 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::para::Float;
|
||||
use crate::para::{Float, GroupChild};
|
||||
|
||||
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
|
||||
#[test]
|
||||
fn older_shape_json_defaults_to_flat_caps() {
|
||||
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(extra.cap, LineCap::Flat);
|
||||
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
|
||||
for cap in [LineCap::Square, LineCap::Round] {
|
||||
let extra = ShapeExtra { cap, ..Default::default() };
|
||||
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
|
||||
}
|
||||
}
|
||||
|
||||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
@@ -255,6 +338,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn group(children: Vec<GroupChild>) -> InlineObject {
|
||||
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_and_ends_are_inverse() {
|
||||
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
|
||||
@@ -275,6 +362,174 @@ mod tests {
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
|
||||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||||
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
|
||||
},
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_both_unresolved_ends() {
|
||||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let kids = vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
|
||||
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
|
||||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
|
||||
(*w, *h) = (400.0, 150.0);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
|
||||
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
|
||||
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
|
||||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.flip_h = free_start;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 3000.0, y: 10.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, .. } = &mut group else { panic!() };
|
||||
*w = 400.0;
|
||||
let visual_free = |group: &InlineObject| {
|
||||
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
|
||||
let ([x, y, w, h], obj) = rects[1];
|
||||
let spin = obj.frame().unwrap().2.spin();
|
||||
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
|
||||
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
|
||||
};
|
||||
let before = visual_free(&group);
|
||||
assert_eq!(before, (6080.0, 10.0));
|
||||
let moved = reroute_group(&mut group);
|
||||
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
|
||||
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
|
||||
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
|
||||
for dims in [
|
||||
[0.0, 300.0, 200.0, 300.0],
|
||||
[200.0, 0.0, 200.0, 300.0],
|
||||
[-200.0, 300.0, 200.0, 300.0],
|
||||
[f32::NAN, 300.0, 200.0, 300.0],
|
||||
[200.0, f32::INFINITY, 200.0, 300.0],
|
||||
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
|
||||
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
|
||||
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
|
||||
[200.0, 300.0, 0.0, 0.0],
|
||||
[200.0, 300.0, f32::NAN, f32::INFINITY],
|
||||
[200.0, 300.0, -200.0, -300.0],
|
||||
] {
|
||||
let mut obj =
|
||||
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
let mut group = group(vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
]);
|
||||
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
|
||||
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
|
||||
let moved = reroute_group(&mut group);
|
||||
// group_rects treats invalid child extents as unit scale; display scales that
|
||||
// cannot be inverted (or whose inverse overflows) must not change the connector.
|
||||
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
|
||||
}
|
||||
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_connectors_follow_their_shapes() {
|
||||
let a = ShapeExtra { id: 7, ..Default::default() };
|
||||
@@ -285,17 +540,40 @@ mod tests {
|
||||
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
|
||||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
|
||||
];
|
||||
assert!(reroute_group(&mut kids));
|
||||
// Fully resolved endpoints must still come from their sites, regardless of the
|
||||
// connector's old rotation and flips.
|
||||
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
float.flip_h = true;
|
||||
float.flip_v = true;
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
|
||||
let c = &kids[2];
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[2];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
|
||||
assert!(!reroute_group(&mut kids), "settled");
|
||||
assert!(!reroute_group(&mut group), "settled");
|
||||
// A fully resolved connector does not need an invertible group scale.
|
||||
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
|
||||
let mut scaled = group.clone();
|
||||
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
|
||||
(*w, *h) = (width, height);
|
||||
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
|
||||
float.rot = 90.0;
|
||||
assert!(reroute_group(&mut scaled));
|
||||
let InlineObject::Group { children, .. } = &scaled else { panic!() };
|
||||
let c = &children[2];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
|
||||
assert!(!reroute_group(&mut scaled));
|
||||
}
|
||||
// A connection to a missing id or site keeps that end where it was.
|
||||
if let InlineObject::Shape { extra, .. } = &mut kids[2].obj {
|
||||
let InlineObject::Group { children, .. } = &mut group else { panic!() };
|
||||
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
|
||||
extra.end = Some(ConnEnd { id: 404, site: 0 });
|
||||
extra.start = Some(ConnEnd { id: 7, site: 99 });
|
||||
}
|
||||
assert!(!reroute_group(&mut kids));
|
||||
assert!(!reroute_group(&mut group));
|
||||
}
|
||||
}
|
||||
|
||||
+45
-21
@@ -14,6 +14,8 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::OBJ;
|
||||
@@ -312,10 +314,10 @@ pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
continue;
|
||||
}
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::ControlStart { control }) => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
Some(InlineObject::ControlEnd) => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -639,34 +641,52 @@ impl Document {
|
||||
out.push((r.end.path.clone(), r.end.off));
|
||||
}
|
||||
}
|
||||
// Markers paired with a table wrap: their partner is never deleted with them.
|
||||
// Markers paired with a table wrap: their partner is never deleted with them. Each lookup
|
||||
// is a set probe, and each container is paired once, not once per marker (#3614).
|
||||
let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect();
|
||||
let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new();
|
||||
for path in &paths {
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if !inside(path, off)
|
||||
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|
||||
|| in_ranges.contains(&(path, off))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let paired = ranges.iter().any(|r| (r.start.path == *path && r.start.off == off) || (r.end.path == *path && r.end.off == off));
|
||||
if !paired && self.paired_with_table(a.story, path, off) {
|
||||
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
|
||||
if paired.contains(&(path.last(), off)) {
|
||||
out.push((path.clone(), off));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches
|
||||
// each group (`control_markers_kept_are_sorted` pins it).
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the marker at `path`/`off` is paired with a table wrap's open or close.
|
||||
fn paired_with_table(&self, s: StoryRef, path: &Path, off: usize) -> bool {
|
||||
let Some(bl) = self.container(s, path) else { return false };
|
||||
/// (block, offset) of the paragraph markers in `path`'s container that are paired with a
|
||||
/// table wrap's open or close.
|
||||
fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> {
|
||||
let Some(bl) = self.container(s, path) else { return BTreeSet::new() };
|
||||
let events = control_events(bl);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
let me = events.iter().position(|e| e.block() == path.last() && e.off() == Some(off));
|
||||
let Some(me) = me else { return false };
|
||||
pairs.iter().any(|(o, c, _)| {
|
||||
(*o == me && events.get(*c).is_some_and(|e| e.off().is_none())) || (*c == me && events.get(*o).is_some_and(|e| e.off().is_none()))
|
||||
})
|
||||
let mut out = BTreeSet::new();
|
||||
for (o, c, _) in pairs {
|
||||
let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue };
|
||||
match (open.off(), close.off()) {
|
||||
(Some(off), None) => {
|
||||
out.insert((open.block(), off));
|
||||
}
|
||||
(None, Some(off)) => {
|
||||
out.insert((close.block(), off));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
|
||||
@@ -674,8 +694,11 @@ impl Document {
|
||||
/// end, goes inside it too (the control is around the whole paragraph).
|
||||
pub fn typing_pos(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlEnd));
|
||||
// The objects once, looked up by offset (object_at per step rescans the text).
|
||||
let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect();
|
||||
let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o);
|
||||
let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd));
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
// Only block starts before the caret (or none) and block starts right after it.
|
||||
@@ -688,8 +711,9 @@ impl Document {
|
||||
}
|
||||
if p.off == para.len() && off >= w && is_end(off - w) {
|
||||
// Step back over the trailing ends that close block-level controls.
|
||||
let ranges = self.control_ranges(p.story);
|
||||
while off >= w && is_end(off - w) && ranges.iter().any(|r| r.end.path == p.path && r.end.off == off - w && r.control.block) {
|
||||
let block_ends: BTreeSet<usize> =
|
||||
self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect();
|
||||
while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) {
|
||||
off -= w;
|
||||
}
|
||||
return Pos { off, ..p.clone() };
|
||||
|
||||
+312
-19
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::InlineObject;
|
||||
use crate::para::{InlineObject, Run};
|
||||
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};
|
||||
@@ -188,6 +188,111 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
|
||||
const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
const MAX_PASTED_BOXES: usize = 4096;
|
||||
/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of
|
||||
/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes
|
||||
/// are shared between those stories, not copied, so they are not charged per box.
|
||||
const MAX_PASTED_BOX_BYTES: usize = 32 << 20;
|
||||
|
||||
/// What a paste may still spend on text box stories.
|
||||
struct BoxBudget {
|
||||
stories: usize,
|
||||
bytes: usize,
|
||||
/// The source stories being copied, innermost last: a box inside one of them showing it
|
||||
/// again is a cycle.
|
||||
active: Vec<u32>,
|
||||
/// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for
|
||||
/// what is left is not walked again for every box that shows it.
|
||||
costs: BTreeMap<u32, usize>,
|
||||
/// Boxes left without a story because the paste's budget couldn't hold theirs.
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
fn new() -> BoxBudget {
|
||||
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rough size of a block's content, and whether it holds text boxes (and so must be copied).
|
||||
fn block_cost(b: &Block) -> (usize, bool) {
|
||||
let (mut cost, mut boxes) = (std::mem::size_of::<Block>(), false);
|
||||
each_para(b, 0, &mut |p| {
|
||||
let items = p
|
||||
.runs
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<Run>())
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its
|
||||
/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts,
|
||||
/// freeforms, wrap polygons, gradients) is not copied, so not counted.
|
||||
fn object_cost(o: &InlineObject) -> usize {
|
||||
let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len());
|
||||
let mut cost = 0usize;
|
||||
let mut todo = vec![o];
|
||||
while let Some(o) = todo.pop() {
|
||||
let add = match o {
|
||||
InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)),
|
||||
InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)),
|
||||
InlineObject::Shape { float: f, extra, .. } => {
|
||||
let warp = extra.warp.as_ref().map_or(0, |w| {
|
||||
let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len()));
|
||||
adj.fold(w.preset.len(), usize::saturating_add)
|
||||
});
|
||||
float(f).saturating_add(warp)
|
||||
}
|
||||
InlineObject::Group { float: f, children, .. } => {
|
||||
todo.extend(children.iter().map(|c| &c.obj));
|
||||
children.len().saturating_mul(std::mem::size_of::<crate::para::GroupChild>()).saturating_add(float(f))
|
||||
}
|
||||
InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()),
|
||||
InlineObject::FieldStart { instr, .. } => instr.len(),
|
||||
InlineObject::ControlStart { control } => std::mem::size_of::<crate::control::ContentControl>().saturating_add(serialized_len(control)),
|
||||
InlineObject::NoteRef { custom, .. } => custom.len(),
|
||||
InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(),
|
||||
InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()),
|
||||
InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()),
|
||||
InlineObject::Ruby { base, ruby, lang, props, .. } => {
|
||||
[base.len(), ruby.len(), lang.len(), std::mem::size_of::<CharProps>(), serialized_len(props)]
|
||||
.into_iter()
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()),
|
||||
InlineObject::FieldEnd
|
||||
| InlineObject::ControlEnd
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::PositionalTab { .. } => 0,
|
||||
};
|
||||
cost = cost.saturating_add(add);
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the
|
||||
/// nested formatting and control types, which share nothing through `Arc`.
|
||||
fn serialized_len<T: Serialize>(v: &T) -> usize {
|
||||
struct Count(usize);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(b.len());
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
let _ = serde_json::to_writer(&mut n, v);
|
||||
n.0
|
||||
}
|
||||
|
||||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||||
@@ -474,7 +579,13 @@ impl Document {
|
||||
}
|
||||
// Content control markers whose partner is outside the range stay (the control is
|
||||
// emptied, not broken).
|
||||
let kept = self.kept_control_markers(&a, &b);
|
||||
let mut kept = self.kept_control_markers(&a, &b);
|
||||
// The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up
|
||||
// (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this
|
||||
// keeps a change there from quietly losing markers.
|
||||
if !kept.is_sorted() {
|
||||
kept.sort();
|
||||
}
|
||||
let kept_in = |path: &Path| -> Vec<usize> { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() };
|
||||
if a.path == b.path {
|
||||
self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?;
|
||||
@@ -485,13 +596,21 @@ impl Document {
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
for (path, off) in &kept {
|
||||
if *path != a.path
|
||||
&& *path != b.path
|
||||
&& let Some(p) = self.para(a.story, path)
|
||||
&& let Some(o) = p.object_at(*off)
|
||||
{
|
||||
moved.push((o.clone(), p.props_of_char(*off).clone()));
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
for group in kept.chunk_by(|x, y| x.0 == y.0) {
|
||||
let Some((path, _)) = group.first() else { continue };
|
||||
if *path == a.path || *path == b.path {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
let mut runs = p.run_ranges().peekable();
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if group.binary_search_by_key(&off, |(_, k)| *k).is_err() {
|
||||
continue;
|
||||
}
|
||||
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
|
||||
let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c);
|
||||
moved.push((o.clone(), props.clone()));
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -744,7 +863,7 @@ impl Document {
|
||||
/// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a
|
||||
/// fragment without them, from this document's text box of that id). A box whose story can't
|
||||
/// be found keeps no story rather than showing an unrelated one.
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) {
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) {
|
||||
let mut any = false;
|
||||
for b in blocks.iter() {
|
||||
each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some());
|
||||
@@ -770,18 +889,53 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || *budget == 0 {
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too. Blocks without
|
||||
/// text boxes are shared with the source (copy-on-write), the others copied. A box showing a
|
||||
/// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400);
|
||||
/// only that box: a later box with a smaller story is still copied while the budget lasts.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) {
|
||||
return None;
|
||||
}
|
||||
if budget.stories == 0 {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||||
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
// Charge what the copy really allocates before making any of it: the block slots, and the
|
||||
// blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many
|
||||
// boxes show them, so they cost nothing more. Worked out once per paste.
|
||||
let cost = *budget.costs.entry(old).or_insert_with(|| {
|
||||
let slots = src.blocks.len().saturating_mul(std::mem::size_of::<Arc<Block>>());
|
||||
src.blocks.iter().fold(slots, |a, b| match block_cost(b) {
|
||||
(c, true) => a.saturating_add(c),
|
||||
(_, false) => a,
|
||||
})
|
||||
});
|
||||
let Some(left) = budget.bytes.checked_sub(cost) else {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
};
|
||||
budget.bytes = left;
|
||||
budget.stories -= 1;
|
||||
let src_blocks = src.blocks.clone();
|
||||
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
*budget -= 1;
|
||||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||||
budget.active.push(old);
|
||||
let mut blocks: Blocks = Vec::with_capacity(src_blocks.len());
|
||||
for b in src_blocks {
|
||||
let mut boxes = false;
|
||||
each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some());
|
||||
if boxes {
|
||||
let mut copy = (*b).clone();
|
||||
self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget);
|
||||
blocks.push(Arc::new(copy));
|
||||
} else {
|
||||
blocks.push(b);
|
||||
}
|
||||
}
|
||||
budget.active.pop();
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks).ok()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
@@ -803,8 +957,15 @@ impl Document {
|
||||
return Err(DocError::BadPath(pos.path.to_string()));
|
||||
}
|
||||
// Pasted text boxes get stories of their own (never shared with the copy's source).
|
||||
let mut budget = MAX_PASTED_BOXES;
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||||
if budget.skipped > 0 {
|
||||
// Once per paste, not once per box (review of #3874).
|
||||
log::warn!(
|
||||
"paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)",
|
||||
budget.skipped
|
||||
);
|
||||
}
|
||||
// Single paragraph: inline insert keeping runs.
|
||||
if blocks.len() == 1
|
||||
&& let Some(Block::Para(src)) = blocks.first()
|
||||
@@ -1196,7 +1357,139 @@ mod tests {
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}");
|
||||
// The copy's inner box would show the story being copied: a cycle, so it gets none.
|
||||
assert_eq!(added, 1);
|
||||
}
|
||||
|
||||
/// A fragment whose body is one paragraph of `refs` text boxes all showing story 1.
|
||||
fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
|
||||
let mut p = Paragraph::new();
|
||||
for _ in 0..refs {
|
||||
p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
|
||||
Fragment { blocks: vec![Block::Para(p)], parts }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_boxes_share_the_blocks_they_need_not_copy() {
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 3);
|
||||
let mut d = doc(&["x"]);
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
let ids: Vec<u32> = got.into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 3, "each box has a story");
|
||||
assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() {
|
||||
// Review of #3874: once one story didn't fit, every later box in the paste was left
|
||||
// empty, however small its story. Only the box that doesn't fit is.
|
||||
// (Story 1 holds a text box, so each box showing it needs a real copy.)
|
||||
let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default());
|
||||
bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let big = para_block(bp);
|
||||
let small = para_block(Paragraph::with_text("small", CharProps::default()));
|
||||
let mut p = Paragraph::new();
|
||||
for story in [2, 1, 2, 1] {
|
||||
p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() });
|
||||
parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() });
|
||||
let mut blocks = vec![Block::Para(p)];
|
||||
let mut d = doc(&["x"]);
|
||||
let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() };
|
||||
d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let got: Vec<Option<u32>> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect();
|
||||
assert_eq!(got.iter().map(Option::is_some).collect::<Vec<_>>(), [false, true, false, true], "{got:?}");
|
||||
assert_eq!(budget.skipped, 2);
|
||||
assert_eq!(budget.costs.len(), 2, "each story costed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_boxes_sharing_one_story_paste_with_their_text() {
|
||||
// Review of #3874: a document converted from PDF has thousands of boxes showing story
|
||||
// data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB
|
||||
// cap) but the block is shared, not copied, so every box keeps its text.
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}");
|
||||
let ids: Vec<u32> = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 64, "every box keeps a story");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box");
|
||||
assert_eq!(d.parts[&id].blocks.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_are_capped_in_bytes() {
|
||||
// Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs
|
||||
// its own copy; 64 of them used to copy 64 MiB.
|
||||
let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
let added = d.parts.len() - before;
|
||||
// The boxes left empty are said to be, once for the paste (review of #3874).
|
||||
let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect();
|
||||
assert_eq!(empty.len(), 1, "{warned:?}");
|
||||
assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}");
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// The boxes past the cap keep no story rather than a shared one.
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
// the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it.
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() };
|
||||
p.insert_object(0, opaque, &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 };
|
||||
let copied: usize =
|
||||
d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// Nested group members and equations count too.
|
||||
let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() };
|
||||
let inner =
|
||||
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None };
|
||||
let outer =
|
||||
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None };
|
||||
assert!(object_cost(&outer) >= 5000);
|
||||
let eq = InlineObject::Equation {
|
||||
linear: String::new(),
|
||||
display: false,
|
||||
math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() },
|
||||
};
|
||||
assert!(object_cost(&eq) >= 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
|
||||
}
|
||||
// A switch: its letters, then an optional (signed) number.
|
||||
Some(_) => {
|
||||
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
|
||||
while it.next_if(char::is_ascii_alphabetic).is_some() {}
|
||||
while it.next_if(|n| *n == ' ').is_some() {}
|
||||
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let path = Path(prefix.clone());
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::FieldStart { instr, locked, .. } => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
Some(InlineObject::FieldEnd) => match open.pop() {
|
||||
InlineObject::FieldEnd => match open.pop() {
|
||||
Some((instr, locked, start)) => {
|
||||
let depth = open.len();
|
||||
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
|
||||
@@ -99,7 +99,10 @@ impl Document {
|
||||
pub fn past_field_ends(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let mut off = p.off;
|
||||
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
|
||||
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
|
||||
if at != off || !matches!(o, InlineObject::FieldEnd) {
|
||||
break;
|
||||
}
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Visible logical text for IME surrounding-text queries.
|
||||
//!
|
||||
//! The platform input method works on what the reader sees, not on the stored run text: tracked
|
||||
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
|
||||
//! build a bounded window around a selection without cloning the paragraph or the document, and
|
||||
//! map both ends in both directions so a query and a delete use exactly the same text.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::para::{OBJ, Paragraph};
|
||||
use crate::{Document, Pos};
|
||||
|
||||
/// A bounded visible-logical window around a selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeWindow {
|
||||
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
|
||||
pub text: String,
|
||||
/// UTF-8 byte offset of the primary end in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// UTF-8 byte offset of the other end in [`Self::text`].
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// The largest visible window an IME query copies.
|
||||
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
||||
|
||||
/// Which unit an input method's delete-surrounding counts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DeleteUnit {
|
||||
/// Unicode scalar values of the visible text.
|
||||
Chars,
|
||||
/// UTF-8 bytes of the visible text.
|
||||
Bytes,
|
||||
}
|
||||
|
||||
/// A deletion request in the paragraph's stored byte offsets.
|
||||
///
|
||||
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeDelete {
|
||||
/// Stored byte range to erase before the selection.
|
||||
pub before: Option<Range<usize>>,
|
||||
/// Stored byte range to erase after the selection.
|
||||
pub after: Option<Range<usize>>,
|
||||
/// Stored byte offset of the selection's anchor **before** the deletions.
|
||||
///
|
||||
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
|
||||
/// them (and shifts these offsets past `before`) itself.
|
||||
pub anchor: usize,
|
||||
/// Stored byte offset of the selection's focus **before** the deletions.
|
||||
pub focus: usize,
|
||||
}
|
||||
|
||||
/// One paragraph's visible logical text.
|
||||
///
|
||||
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
|
||||
/// lazy: no full visible copy is made.
|
||||
pub struct VisibleParagraph<'a> {
|
||||
para: &'a Paragraph,
|
||||
deleted: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> VisibleParagraph<'a> {
|
||||
/// A view of `para` with tracked deletions hidden.
|
||||
pub fn new(para: &'a Paragraph) -> Self {
|
||||
let mut deleted = para.deleted_ranges();
|
||||
deleted.sort_unstable_by_key(|r| r.start);
|
||||
Self { para, deleted }
|
||||
}
|
||||
|
||||
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
|
||||
/// before it.
|
||||
///
|
||||
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
|
||||
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
|
||||
/// range.
|
||||
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
|
||||
while let Some(r) = self.deleted.get(*next) {
|
||||
if r.end > off {
|
||||
break;
|
||||
}
|
||||
*next = next.saturating_add(1);
|
||||
}
|
||||
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
|
||||
}
|
||||
|
||||
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
|
||||
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
|
||||
self.for_each_piece_while(|start, piece, stored| {
|
||||
f(start, piece, stored);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Call `f` for visible pieces until it returns `false`.
|
||||
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let keep_going = if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
f(i, piece, OBJ.len_utf8())
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
f(i, c.encode_utf8(&mut buf), c.len_utf8())
|
||||
};
|
||||
if !keep_going {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total visible UTF-8 bytes.
|
||||
pub fn visible_len(&self) -> usize {
|
||||
let mut len = 0usize;
|
||||
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
|
||||
len
|
||||
}
|
||||
|
||||
/// Visible offset of a stored paragraph byte offset.
|
||||
///
|
||||
/// `None` when the stored offset is not a character boundary, points inside a tracked
|
||||
/// deletion, or would split an inline object's plain-text result.
|
||||
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
|
||||
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
|
||||
return None;
|
||||
}
|
||||
let mut visible = 0usize;
|
||||
let mut found = false;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if i == stored {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if i > stored {
|
||||
break;
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
// A position at the start of a deleted run is still a visible position; one
|
||||
// inside it is not.
|
||||
continue;
|
||||
}
|
||||
if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
visible = visible.saturating_add(piece.len());
|
||||
} else {
|
||||
visible = visible.saturating_add(c.len_utf8());
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The end of the paragraph, or a deleted run at its end.
|
||||
let mut next = 0usize;
|
||||
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
|
||||
}
|
||||
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
|
||||
// The start of a deleted run is still a visible caret position; only a position
|
||||
// strictly inside one is unrepresentable.
|
||||
let at_start = self.deleted.iter().any(|r| r.start == stored);
|
||||
return at_start.then_some(visible);
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
|
||||
/// Stored paragraph byte offset for a visible offset.
|
||||
///
|
||||
/// `None` when the visible offset falls inside an inline object's plain-text result or is
|
||||
/// not a boundary of the visible representation.
|
||||
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut answer = None;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc {
|
||||
answer = Some(i);
|
||||
break;
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
// Inside a character (or an object's expansion): no stored boundary there.
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
|
||||
}
|
||||
|
||||
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
|
||||
/// the same boundary.
|
||||
///
|
||||
/// A content control's markers are inline objects with no plain text, so they share a
|
||||
/// visible offset with the content just inside (or after) them. A range start must map
|
||||
/// after a control-start marker and after a control-end marker, never onto the marker
|
||||
/// itself: an IME deletion must not break the control. Non-empty object expansions still
|
||||
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
|
||||
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc && piece_len > 0 {
|
||||
return Some(i);
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Stored offset for a visible offset, preferring the position **before** a tracked
|
||||
/// deletion that starts there. Use this for the end of a range so a deletion run just
|
||||
/// after the caret is never drawn into it.
|
||||
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if visible == acc {
|
||||
return Some(i);
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
|
||||
pub fn ceil_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = None;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
return;
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
}
|
||||
});
|
||||
out.unwrap_or(acc)
|
||||
}
|
||||
|
||||
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
|
||||
pub fn floor_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = 0usize;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
let next = acc.saturating_add(piece.len());
|
||||
if next > visible {
|
||||
return;
|
||||
}
|
||||
acc = next;
|
||||
out = acc;
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
|
||||
///
|
||||
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
|
||||
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
|
||||
/// visible text returns `None` (unavailable, not fabricated).
|
||||
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
|
||||
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
|
||||
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
|
||||
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection_len = sel_end - sel_start;
|
||||
if selection_len > max_bytes {
|
||||
return None;
|
||||
}
|
||||
// Spend the budget around the selection and stop walking at its end: the paragraph is
|
||||
// never copied and the work is bounded by the caret's distance from the paragraph start.
|
||||
let before = (max_bytes - selection_len) / 2;
|
||||
let window_start = sel_start.saturating_sub(before);
|
||||
let window_end = window_start.saturating_add(max_bytes);
|
||||
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
|
||||
let mut first_kept = None;
|
||||
let mut acc = 0usize;
|
||||
self.for_each_piece_while(|_, piece, _| {
|
||||
for (k, c) in piece.char_indices() {
|
||||
let start = acc.saturating_add(k);
|
||||
let end = start.saturating_add(c.len_utf8());
|
||||
if end > window_end {
|
||||
return false;
|
||||
}
|
||||
if start >= window_start {
|
||||
if first_kept.is_none() {
|
||||
first_kept = Some(start);
|
||||
}
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
acc < window_end
|
||||
});
|
||||
let Some(first) = first_kept else {
|
||||
// An empty document is an available empty context, never unavailable.
|
||||
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
|
||||
};
|
||||
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
|
||||
///
|
||||
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
|
||||
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
|
||||
}
|
||||
|
||||
/// Delete-surrounding ranges in the stored paragraph text.
|
||||
///
|
||||
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
|
||||
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
|
||||
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
|
||||
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
|
||||
/// cannot be represented in stored text returns `None` instead of guessing; so does a
|
||||
/// request with nothing to erase (`0` on both sides).
|
||||
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
if before == 0 && after == 0 {
|
||||
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
|
||||
// discover it.
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
let (sel_start, sel_end) = (c.min(a), c.max(a));
|
||||
|
||||
let (before_start, after_end) = match unit {
|
||||
DeleteUnit::Bytes => {
|
||||
let total = view.visible_len();
|
||||
if before > sel_start || after > total - sel_end {
|
||||
return None;
|
||||
}
|
||||
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
|
||||
}
|
||||
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
|
||||
};
|
||||
|
||||
// Start a range after a deletion run and end it before one, so tracked deletions are
|
||||
// never part of the erased stored range.
|
||||
let before_start = view.stored_offset_after(before_start)?;
|
||||
let before_end = view.stored_offset_before(sel_start)?;
|
||||
let after_start = view.stored_offset_after(sel_end)?;
|
||||
let after_end = view.stored_offset_before(after_end)?;
|
||||
let before_range = (before_start < before_end).then_some(before_start..before_end);
|
||||
let after_range = (after_start < after_end).then_some(after_start..after_end);
|
||||
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
|
||||
/// characters on from `sel_end`.
|
||||
///
|
||||
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
|
||||
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
|
||||
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
|
||||
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
|
||||
let mut acc = 0usize;
|
||||
let mut chars = 0usize;
|
||||
let mut start_index = None;
|
||||
let mut end_index = None;
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if at == sel_start {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if at == sel_end {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
chars = chars.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if sel_start == acc {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if sel_end == acc {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
let start_index = start_index?;
|
||||
let end_index = end_index?;
|
||||
let target_before = start_index.saturating_sub(before);
|
||||
let target_after = end_index.saturating_add(after).min(chars);
|
||||
|
||||
let mut acc = 0usize;
|
||||
let mut index = 0usize;
|
||||
let mut before_start = None;
|
||||
let mut after_end = (target_after == 0).then_some(0);
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(at);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(at);
|
||||
}
|
||||
index = index.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(acc);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(acc);
|
||||
}
|
||||
Some((before_start?, after_end?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::para::Run;
|
||||
use crate::props::CharProps;
|
||||
use crate::{InlineObject, StoryRef};
|
||||
|
||||
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
|
||||
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
for (text, deleted) in parts {
|
||||
let mut props = CharProps::default();
|
||||
if *deleted {
|
||||
props.del = Some(0);
|
||||
}
|
||||
p.text.push_str(text);
|
||||
p.runs.push(Run { len: text.len(), props });
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
|
||||
/// visible caret position, its interior is not, and a window concatenates only visible text.
|
||||
#[test]
|
||||
fn tracked_deletions_are_hidden_and_offsets_still_map() {
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 4);
|
||||
assert_eq!(view.visible_offset(0), Some(0));
|
||||
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
|
||||
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
|
||||
assert_eq!(view.visible_offset(5), Some(2));
|
||||
assert_eq!(view.visible_offset(7), Some(4));
|
||||
assert_eq!(view.stored_offset(2), Some(5));
|
||||
assert_eq!(view.stored_offset(4), Some(7));
|
||||
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
|
||||
}
|
||||
|
||||
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
|
||||
/// strictly inside that expansion have no stored boundary.
|
||||
#[test]
|
||||
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 5);
|
||||
assert_eq!(view.visible_offset(1), Some(1));
|
||||
assert_eq!(view.visible_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(1), Some(1));
|
||||
assert_eq!(view.stored_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(2), None, "inside the object's text");
|
||||
}
|
||||
|
||||
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
|
||||
/// yields an available empty context too, though this test asserts only its text, not its offsets.
|
||||
#[test]
|
||||
fn an_empty_document_is_available_and_empty() {
|
||||
let p = Paragraph::new();
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
|
||||
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
|
||||
let doc = Document::new();
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
|
||||
assert_eq!(w.text, "");
|
||||
}
|
||||
|
||||
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
|
||||
/// selection wider than the budget is unavailable.
|
||||
#[test]
|
||||
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
|
||||
assert_eq!(w.cursor, w.anchor);
|
||||
assert!(w.cursor <= w.text.len());
|
||||
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
|
||||
}
|
||||
|
||||
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
|
||||
/// values whole, and refuses counts that run past the paragraph.
|
||||
#[test]
|
||||
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
|
||||
let doc = Document::from_text("aé👍b");
|
||||
let caret = Pos::body(0, 8);
|
||||
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(one.before, Some(7..8));
|
||||
// Two bytes start inside the emoji: it is left whole, only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(two.before, Some(7..8));
|
||||
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
|
||||
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
|
||||
assert_eq!(six.before, Some(3..8));
|
||||
// A count beyond the paragraph is refused, not clipped to delete a whole side.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
|
||||
}
|
||||
|
||||
/// Surrounding deletion skips the selected range on both sides and returns the selection's
|
||||
/// original reversed anchor and focus.
|
||||
#[test]
|
||||
fn byte_deletion_excludes_a_reversed_selection() {
|
||||
let doc = Document::from_text("abcd");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(0, 3);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(0..1), "only the character before the selection");
|
||||
assert_eq!(del.after, Some(3..4), "only the character after the selection");
|
||||
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
|
||||
}
|
||||
|
||||
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
|
||||
/// edit cannot remove them.
|
||||
#[test]
|
||||
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
|
||||
let mut doc = Document::from_text("abDELcd");
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
|
||||
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
|
||||
let caret = Pos::body(0, 7);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
|
||||
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
|
||||
}
|
||||
|
||||
/// A selection spanning paragraphs has no single surrounding context: both the query and
|
||||
/// surrounding deletion return `None`.
|
||||
#[test]
|
||||
fn cross_paragraph_selection_is_unavailable() {
|
||||
let doc = Document::from_text("one\ntwo");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(1, 1);
|
||||
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
|
||||
}
|
||||
|
||||
/// An object's plain-text result stands for the object in the visible text; a deletion
|
||||
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
|
||||
#[test]
|
||||
fn object_expansion_is_never_partly_deleted() {
|
||||
let mut doc = Document::from_text("");
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let caret = Pos::body(0, 5);
|
||||
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
|
||||
// forward past the object, so only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
|
||||
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
|
||||
let mut remaining = format!("a{OBJ}b");
|
||||
remaining.replace_range(two.before.clone().unwrap(), "");
|
||||
assert_eq!(remaining, format!("a{OBJ}"));
|
||||
// A character that lands inside the expansion has no stored boundary: refused.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
|
||||
// A count reaching before the object erases the object as a whole.
|
||||
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
|
||||
assert_eq!(five.before, Some(0..5));
|
||||
}
|
||||
|
||||
/// Character counts on the right side clamp at the paragraph's end and never cross into the
|
||||
/// next paragraph.
|
||||
#[test]
|
||||
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
|
||||
let doc = Document::from_text("ab\ncd");
|
||||
let caret = Pos::body(0, 1);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
|
||||
assert_eq!(del.after, Some(1..2));
|
||||
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
|
||||
assert!(empty.is_none(), "nothing to erase");
|
||||
}
|
||||
|
||||
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
|
||||
/// (and linear) instead of each character scanning every range.
|
||||
#[test]
|
||||
fn alternating_tracked_revisions_map_correctly() {
|
||||
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
|
||||
let parts: Vec<(String, bool)> =
|
||||
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
|
||||
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
|
||||
let p = para_with_runs(&parts);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
|
||||
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
|
||||
let w = view.window(0, 0, 100).expect("a window");
|
||||
assert!(w.text.len() <= 100);
|
||||
assert!(!w.text.contains('D'), "tracked deletions never show");
|
||||
}
|
||||
|
||||
/// The public window never copies more than the IME cap, whatever a caller asks for.
|
||||
#[test]
|
||||
fn window_budget_is_capped() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
let doc = Document::from_text(&"a".repeat(10_000));
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
}
|
||||
|
||||
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
|
||||
#[test]
|
||||
fn table_cell_paragraph_maps_inside_its_cell() {
|
||||
let mut doc = Document::from_text("outside");
|
||||
let mut table = crate::Table::new(1, 1, 100.0);
|
||||
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
|
||||
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
|
||||
let path = crate::Path(vec![1, 0, 0, 0]);
|
||||
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
|
||||
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
|
||||
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
|
||||
assert_eq!(w.text, "cell");
|
||||
assert_eq!(w.cursor, 4);
|
||||
assert_eq!(w.anchor, 2);
|
||||
}
|
||||
|
||||
/// Content-control markers are zero-length visible objects. A deletion range starts after
|
||||
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
|
||||
/// or leaves a half-paired control.
|
||||
#[test]
|
||||
fn control_markers_are_not_included_in_delete_ranges() {
|
||||
use crate::control::{ContentControl, ControlKind};
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("pre{OBJ}Ada{OBJ}X");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
let mut ctl = ContentControl::new(ControlKind::RichText);
|
||||
ctl.placeholder_text = "Enter name".into();
|
||||
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
|
||||
p.objects.push(InlineObject::ControlEnd);
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
|
||||
let cursor = Pos::body(0, 9);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
|
||||
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
|
||||
assert_eq!((del.anchor, del.focus), (9, 9));
|
||||
}
|
||||
}
|
||||
+275
-48
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod bidi;
|
||||
mod byte_store;
|
||||
pub mod cfb_guard;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
@@ -26,6 +27,7 @@ pub mod fields;
|
||||
pub mod fill;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod ime_text;
|
||||
pub mod legacy_form;
|
||||
pub mod line_dash;
|
||||
pub mod link;
|
||||
@@ -179,6 +181,95 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
}
|
||||
|
||||
/// The same blocks: the same content. The blocks are compared by address first — the
|
||||
/// document is copy-on-write, so a block that changed was copied and has another address —
|
||||
/// and a block that was copied but holds the same content is compared by value.
|
||||
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
|
||||
}
|
||||
|
||||
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
|
||||
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
|
||||
fn same_block(a: &Block, b: &Block) -> bool {
|
||||
match (a, b) {
|
||||
(Block::Table(x), Block::Table(y)) => same_table(x, y),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same table. Every field is listed, so a new one does not build until someone decides
|
||||
/// whether a change to it counts.
|
||||
fn same_table(a: &Table, b: &Table) -> bool {
|
||||
let Table { props, grid, rows, controls } = a;
|
||||
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& grid == other_grid
|
||||
&& controls == other_controls
|
||||
&& rows.len() == other_rows.len()
|
||||
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
|
||||
}
|
||||
|
||||
fn same_row(a: &Row, b: &Row) -> bool {
|
||||
let Row { props, cells, controls } = a;
|
||||
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& controls == other_controls
|
||||
&& cells.len() == other_cells.len()
|
||||
&& cells.iter().zip(other_cells).all(|(x, y)| {
|
||||
let Cell { props, blocks, controls } = x;
|
||||
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
|
||||
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same building blocks, their content compared by address first.
|
||||
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| {
|
||||
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
|
||||
let BuildingBlock {
|
||||
name: other_name,
|
||||
gallery: other_gallery,
|
||||
category: other_category,
|
||||
description: other_description,
|
||||
guid: other_guid,
|
||||
style: other_style,
|
||||
types: other_types,
|
||||
behaviors: other_behaviors,
|
||||
blocks: other_blocks,
|
||||
} = y;
|
||||
name == other_name
|
||||
&& gallery == other_gallery
|
||||
&& category == other_category
|
||||
&& description == other_description
|
||||
&& guid == other_guid
|
||||
&& style == other_style
|
||||
&& types == other_types
|
||||
&& behaviors == other_behaviors
|
||||
&& same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same parts: the same keys, and each part the same.
|
||||
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
|
||||
}
|
||||
|
||||
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
|
||||
/// does not build until someone decides whether a change to it counts.
|
||||
fn same_part(a: &Part, b: &Part) -> bool {
|
||||
let Part { kind, blocks, body } = a;
|
||||
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
|
||||
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
|
||||
}
|
||||
|
||||
/// The same bytes: the same content. The bytes are compared by address first — a changed
|
||||
/// picture was copied, so it has another address — and a picture that was copied but holds
|
||||
/// the same bytes is compared by value.
|
||||
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
|
||||
}
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -229,7 +320,7 @@ impl Path {
|
||||
|
||||
impl std::fmt::Display for Path {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
|
||||
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
|
||||
write!(f, "{}", s.join("/"))
|
||||
}
|
||||
}
|
||||
@@ -356,7 +447,8 @@ pub struct Comment {
|
||||
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub durable_id: Option<String>,
|
||||
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
|
||||
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
|
||||
/// or the time WordCraft stamped the comment with.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_utc: Option<String>,
|
||||
}
|
||||
@@ -549,6 +641,10 @@ pub struct Settings {
|
||||
/// after plus the second's space before, not the larger of the two.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
|
||||
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub do_not_expand_shift_return: bool,
|
||||
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
|
||||
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
|
||||
pub allow_space_of_same_style_in_table: bool,
|
||||
@@ -715,6 +811,7 @@ impl Default for Settings {
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
roman_fonts: Vec::new(),
|
||||
sum_paragraph_spacing: false,
|
||||
do_not_expand_shift_return: false,
|
||||
allow_space_of_same_style_in_table: false,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
@@ -762,6 +859,50 @@ pub struct Document {
|
||||
/// The document's own building blocks (its glossary document), in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub glossary: Vec<BuildingBlock>,
|
||||
/// Lookup cache for [`Document::add_media`]; not part of the document (never saved, ignored
|
||||
/// by `==`).
|
||||
#[serde(skip)]
|
||||
media_index: MediaIndex,
|
||||
}
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate.
|
||||
#[derive(Clone, Default)]
|
||||
struct MediaIndex {
|
||||
by_bytes: std::collections::HashMap<Arc<Vec<u8>>, String>,
|
||||
/// `media.len()` when the index last matched it.
|
||||
len: usize,
|
||||
/// The number after the last generated `image{n}` key, so taken names are probed once.
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl MediaIndex {
|
||||
fn rebuild(&mut self, media: &BTreeMap<String, Arc<Vec<u8>>>) {
|
||||
self.by_bytes.clear();
|
||||
// The first key (in key order) of equal bytes wins, as a scan would find it.
|
||||
for (k, v) in media.iter().rev() {
|
||||
self.by_bytes.insert(v.clone(), k.clone());
|
||||
}
|
||||
self.len = media.len();
|
||||
self.next = 0;
|
||||
}
|
||||
|
||||
fn find(&self, media: &BTreeMap<String, Arc<Vec<u8>>>, bytes: &Vec<u8>) -> Option<String> {
|
||||
self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MediaIndex {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaIndex")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -790,6 +931,7 @@ impl Document {
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,34 +943,36 @@ impl Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
fn fix(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
fix_cell(&mut c.blocks);
|
||||
}
|
||||
fn fix_cell_blocks(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_story_blocks(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
Self::fix_cell_blocks(&mut c.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fix_cell(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
fix(&mut self.body);
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
Self::fix_story_blocks(&mut self.body);
|
||||
for p in self.parts.values_mut() {
|
||||
if p.blocks.is_empty() {
|
||||
p.blocks.push(para_block(Paragraph::new()));
|
||||
@@ -836,6 +980,72 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document holds the same content as `other`: the same blocks, the same
|
||||
/// styles, lists, settings, comments and the rest.
|
||||
///
|
||||
/// The blocks are compared by address first: the document is copy-on-write, so a block
|
||||
/// that changed was copied and has another address. Comparing them by value would walk
|
||||
/// the whole document, and this is asked after every command.
|
||||
///
|
||||
/// Every field is listed, so a new one does not build until someone decides whether a
|
||||
/// change to it counts (the same rule the session's edit snapshot follows).
|
||||
pub fn same_content(&self, other: &Document) -> bool {
|
||||
let Document {
|
||||
body,
|
||||
last_section,
|
||||
parts,
|
||||
styles,
|
||||
numbering,
|
||||
comments,
|
||||
revisions,
|
||||
settings,
|
||||
core,
|
||||
sources,
|
||||
custom_props,
|
||||
footnote_separators,
|
||||
endnote_separators,
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
media_index: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
last_section: other_section,
|
||||
parts: other_parts,
|
||||
styles: other_styles,
|
||||
numbering: other_numbering,
|
||||
comments: other_comments,
|
||||
revisions: other_revisions,
|
||||
settings: other_settings,
|
||||
core: other_core,
|
||||
sources: other_sources,
|
||||
custom_props: other_props,
|
||||
footnote_separators: other_footnotes,
|
||||
endnote_separators: other_endnotes,
|
||||
media: other_media,
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
media_index: _,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
&& last_section == other_section
|
||||
&& styles == other_styles
|
||||
&& numbering == other_numbering
|
||||
&& comments == other_comments
|
||||
&& revisions == other_revisions
|
||||
&& settings == other_settings
|
||||
&& core == other_core
|
||||
&& sources == other_sources
|
||||
&& custom_props == other_props
|
||||
&& footnote_separators == other_footnotes
|
||||
&& endnote_separators == other_endnotes
|
||||
&& same_bytes(media, other_media)
|
||||
&& same_bytes(passthrough, other_passthrough)
|
||||
&& same_glossary(glossary, other_glossary)
|
||||
}
|
||||
|
||||
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Some(&self.body),
|
||||
@@ -939,8 +1149,8 @@ impl Document {
|
||||
for story in stories {
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if o.text_boxes().contains(&part) {
|
||||
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
|
||||
}
|
||||
}
|
||||
@@ -1400,17 +1610,32 @@ impl Document {
|
||||
|
||||
/// Add a media item; returns its key (deduplicated by content).
|
||||
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
|
||||
if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) {
|
||||
return k.clone();
|
||||
let ix = &mut self.media_index;
|
||||
if ix.len != self.media.len() {
|
||||
ix.rebuild(&self.media);
|
||||
}
|
||||
let n = self.media.len() + 1;
|
||||
let mut key = format!("image{n}.{ext}");
|
||||
let mut i = n;
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
if ix.by_bytes.contains_key(&bytes) {
|
||||
// Stale (`media` was edited directly without changing its size): look again.
|
||||
ix.rebuild(&self.media);
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
let n = self.media.len().saturating_add(1);
|
||||
let mut i = n.max(ix.next);
|
||||
let mut key = format!("image{i}.{ext}");
|
||||
while self.media.contains_key(&key) {
|
||||
i += 1;
|
||||
i = i.saturating_add(1);
|
||||
key = format!("image{i}.{ext}");
|
||||
}
|
||||
self.media.insert(key.clone(), Arc::new(bytes));
|
||||
let bytes = Arc::new(bytes);
|
||||
self.media.insert(key.clone(), bytes.clone());
|
||||
ix.by_bytes.entry(bytes).or_insert_with(|| key.clone());
|
||||
ix.len = self.media.len();
|
||||
ix.next = i.saturating_add(1);
|
||||
key
|
||||
}
|
||||
|
||||
@@ -1445,22 +1670,22 @@ impl Document {
|
||||
let mut out: Vec<PermRange> = Vec::new();
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
// Open ranges by id, latest last (an end closes the latest open range of its id).
|
||||
let mut open: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::PermStart { id, group, editor }) => {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::PermStart { id, group, editor } => {
|
||||
let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() };
|
||||
open.push(out.len());
|
||||
open.entry(*id).or_default().push(out.len());
|
||||
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
|
||||
}
|
||||
Some(InlineObject::PermEnd { id }) => {
|
||||
if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) {
|
||||
let i = open.remove(k);
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
InlineObject::PermEnd { id } => {
|
||||
if let Some(i) = open.get_mut(id).and_then(Vec::pop)
|
||||
&& let Some(r) = out.get_mut(i)
|
||||
{
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1469,7 +1694,7 @@ impl Document {
|
||||
}
|
||||
// A range left open runs to the end of its story.
|
||||
let end = self.end_of(story);
|
||||
for i in open {
|
||||
for i in open.into_values().flatten() {
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = end.clone();
|
||||
}
|
||||
@@ -1483,8 +1708,8 @@ impl Document {
|
||||
let mut out = Vec::new();
|
||||
for path in self.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = self.para(StoryRef::Body, &path) {
|
||||
for off in p.object_offsets() {
|
||||
if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
@@ -1666,5 +1891,7 @@ fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::Styl
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -102,6 +102,9 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
|
||||
return None;
|
||||
}
|
||||
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
@@ -151,6 +154,15 @@ mod tests {
|
||||
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_digit_strings_are_not_numbers() {
|
||||
assert_eq!(parse_number(&"1".repeat(350), None), None);
|
||||
assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None);
|
||||
assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None);
|
||||
let big = parse_number(&"1".repeat(300), None);
|
||||
assert!(big.is_some_and(f64::is_finite), "{big:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuban_and_peruvian_numbers_use_decimal_points() {
|
||||
for lang in [Some("es-CU"), Some("es-PE")] {
|
||||
|
||||
@@ -41,6 +41,24 @@ impl Math {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone copies: every node, its argument lists and its run text.
|
||||
/// Iterative, so it costs at most the size of the structure.
|
||||
pub(crate) fn heap_bytes(&self) -> usize {
|
||||
let mut n = self.omml.len();
|
||||
let mut todo: Vec<&MNode> = self.nodes.iter().collect();
|
||||
while let Some(node) = todo.pop() {
|
||||
n = n.saturating_add(std::mem::size_of::<MNode>());
|
||||
if let MNode::Run(r) = node {
|
||||
n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
|
||||
}
|
||||
each_arg(node, &mut |a| {
|
||||
n = n.saturating_add(std::mem::size_of::<Arg>());
|
||||
todo.extend(a);
|
||||
});
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// Display equation justification (`m:oMathParaPr/m:jc`).
|
||||
@@ -1095,4 +1113,20 @@ mod tests {
|
||||
let deep = "√".repeat(3000);
|
||||
let _ = parse_linear(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_letters_parses_in_linear_time() {
|
||||
// Each letter that doesn't start a function name used to rescan (and copy) the rest of
|
||||
// the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
|
||||
// loaded runner would fail it; a regression shows as a slow test.)
|
||||
for _ in 0..5 {
|
||||
let e = parse_linear(&"x".repeat(30_000));
|
||||
assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
|
||||
}
|
||||
// Function names are still found at the start of a run, and only as whole runs.
|
||||
assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
|
||||
assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
match c {
|
||||
'\\' => {
|
||||
let start = i;
|
||||
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
|
||||
i += 1;
|
||||
}
|
||||
if i == start {
|
||||
@@ -324,6 +324,21 @@ impl Lp {
|
||||
let den = self.arg();
|
||||
one(MNode::Frac { kind: FracKind::Bar, num, den })
|
||||
}
|
||||
"genfrac" => {
|
||||
let beg = delim_arg(&self.arg());
|
||||
let end = delim_arg(&self.arg());
|
||||
let thickness = self.arg();
|
||||
let _style = self.arg();
|
||||
let num = self.arg();
|
||||
let den = self.arg();
|
||||
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
|
||||
let frac = MNode::Frac { kind, num, den };
|
||||
if beg.is_none() && end.is_none() {
|
||||
one(frac)
|
||||
} else {
|
||||
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
|
||||
}
|
||||
}
|
||||
"binom" | "dbinom" | "tbinom" => {
|
||||
let n = self.arg();
|
||||
let k = self.arg();
|
||||
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
|
||||
fn delim_arg(a: &[MNode]) -> Option<char> {
|
||||
let [MNode::Run(r)] = a else { return None };
|
||||
let mut cs = r.text.chars();
|
||||
match (cs.next(), cs.next()) {
|
||||
(Some(c), None) if c != '.' => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
|
||||
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
|
||||
fn is_zero_length(a: &[MNode]) -> bool {
|
||||
let mut text = String::new();
|
||||
for n in a {
|
||||
let MNode::Run(r) = n else { return false };
|
||||
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
|
||||
}
|
||||
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
|
||||
let unit = &text[number.len()..];
|
||||
!number.is_empty()
|
||||
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
|
||||
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
|
||||
}
|
||||
|
||||
fn delim_tex(c: Option<char>) -> String {
|
||||
match c {
|
||||
None => ".".into(),
|
||||
@@ -1076,6 +1116,34 @@ mod tests {
|
||||
let _ = (LimLoc::UndOvr, MathJc::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_binom_round_trip() {
|
||||
let a = parse_latex(r"\binom{n}{k}");
|
||||
let l = to_latex(&a);
|
||||
assert_eq!(parse_latex(&l), a, "exported as {l}");
|
||||
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
|
||||
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
|
||||
assert_eq!(b, vec![nobar("n", "k")]);
|
||||
assert_eq!(parse_latex(&to_latex(&b)), b);
|
||||
// Other spellings of a zero thickness.
|
||||
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
|
||||
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
|
||||
}
|
||||
// A non-zero or default thickness keeps the bar.
|
||||
for t in ["1pt", "0.4pt", ""] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
|
||||
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
|
||||
assert_eq!(c, vec![bar], "thickness {t:?}");
|
||||
}
|
||||
// Delimiters are kept.
|
||||
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
|
||||
assert_eq!(d, a);
|
||||
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
|
||||
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
|
||||
assert_eq!(parse_latex(&to_latex(&e)), e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_round_trip() {
|
||||
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sinx+cosy", "e^(-x^2)"] {
|
||||
|
||||
@@ -18,6 +18,18 @@ pub const FUNCTION_NAMES: &[&str] = &[
|
||||
"tanh",
|
||||
];
|
||||
|
||||
/// Length of the longest of [`FUNCTION_NAMES`].
|
||||
const MAX_NAME: usize = {
|
||||
let (mut max, mut i) = (0, 0);
|
||||
while i < FUNCTION_NAMES.len() {
|
||||
if FUNCTION_NAMES[i].len() > max {
|
||||
max = FUNCTION_NAMES[i].len();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
max
|
||||
};
|
||||
|
||||
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
|
||||
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
|
||||
|
||||
@@ -339,7 +351,7 @@ impl P {
|
||||
/// A prefix structure after its character.
|
||||
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
|
||||
match c {
|
||||
'√' | '∛' | '∜' => {
|
||||
'√'..='∜' => {
|
||||
let mut deg = match c {
|
||||
'∛' => vec![text('3')],
|
||||
'∜' => vec![text('4')],
|
||||
@@ -410,10 +422,12 @@ impl P {
|
||||
fn word(&mut self, stops: &[char]) -> Arg {
|
||||
let start = self.i;
|
||||
let mut end = start;
|
||||
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
// Look no further than the longest name (and one past it, to see the run is longer): a
|
||||
// scan to the end of every run made a long run of letters quadratic (CWE-407).
|
||||
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
end += 1;
|
||||
}
|
||||
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
|
||||
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
|
||||
if !FUNCTION_NAMES.contains(&word.as_str()) {
|
||||
self.i = start + 1;
|
||||
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
|
||||
@@ -628,7 +642,7 @@ impl P {
|
||||
// Word (#1609): the whole run of letters and digits up to a space or operator, so
|
||||
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
|
||||
let start = self.i;
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
|
||||
while self.peek().is_some_and(char::is_alphanumeric) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
|
||||
@@ -357,7 +357,8 @@ impl Counters {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
let mut chars = def.text.chars().peekable();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
@@ -375,6 +376,9 @@ impl Counters {
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
@@ -383,10 +387,29 @@ impl Counters {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
const MAX_LEVEL_TEXT: usize = 64;
|
||||
/// Labels stop growing once they pass this many bytes.
|
||||
const MAX_LABEL_BYTES: usize = 256;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, huge)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
assert!(label.len() <= 512, "label is {} bytes", label.len());
|
||||
assert!(label.starts_with("MMMM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_s_own_level_definitions_win() {
|
||||
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
|
||||
|
||||
@@ -311,7 +311,14 @@ pub enum ShapeKind {
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
|
||||
Arrow,
|
||||
/// A block arrow pointing left (`leftArrow`).
|
||||
ArrowLeft,
|
||||
/// A block arrow pointing up (`upArrow`).
|
||||
ArrowUp,
|
||||
/// A block arrow pointing down (`downArrow`).
|
||||
ArrowDown,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
@@ -1082,6 +1089,12 @@ impl Paragraph {
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
|
||||
@@ -1552,6 +1565,11 @@ mod tests {
|
||||
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
|
||||
let off = p.object_offsets()[1];
|
||||
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
|
||||
// The one-pass walk agrees with offsets + object_at.
|
||||
let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect();
|
||||
let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect();
|
||||
assert_eq!(one_pass, each);
|
||||
assert_eq!(one_pass.len(), 2);
|
||||
assert_eq!(p.plain_text(), "a1b");
|
||||
p.delete(0, 3).unwrap();
|
||||
assert_eq!(p.objects.len(), 1);
|
||||
|
||||
@@ -342,6 +342,9 @@ impl Highlight {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text size, in points, of a run whose size no style or default sets.
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
|
||||
|
||||
/// Character formatting (direct or in a style). `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
||||
@@ -78,7 +78,7 @@ impl ResolvedChar {
|
||||
ResolvedChar {
|
||||
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
|
||||
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
bold: c.bold.unwrap_or(false),
|
||||
italic: c.italic.unwrap_or(false),
|
||||
underline: c.underline.unwrap_or_default(),
|
||||
@@ -113,7 +113,7 @@ impl ResolvedChar {
|
||||
cs: c.cs.unwrap_or(false),
|
||||
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
font_ea: c.font_ea.clone(),
|
||||
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
|
||||
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
@@ -268,6 +268,8 @@ impl NumFormat {
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
|
||||
NumFormat::RussianLower => russian_letters(n),
|
||||
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
|
||||
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
@@ -308,8 +310,6 @@ impl NumFormat {
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
| NumFormat::ArabicAbjad
|
||||
| NumFormat::HindiVowels
|
||||
| NumFormat::HindiConsonants
|
||||
| NumFormat::HindiNumbers
|
||||
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
|
||||
const ARABIC_ALPHA: [char; 28] =
|
||||
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
|
||||
|
||||
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
|
||||
const ARABIC_ABJAD: [char; 28] =
|
||||
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
|
||||
|
||||
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
|
||||
fn repeated_letter(alphabet: &[char], n: u32) -> String {
|
||||
let Some(&first) = alphabet.first() else { return n.to_string() };
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = alphabet.len() as u32;
|
||||
let n = n.min(len * 30);
|
||||
let k = (n - 1) / len + 1;
|
||||
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
@@ -920,6 +941,19 @@ mod tests {
|
||||
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_letters_count_in_both_orders() {
|
||||
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
|
||||
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
|
||||
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
|
||||
assert_eq!(abjad.join(" "), "أ ب ج د ه");
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
|
||||
// Past the 28th letter they double, as Latin and Russian letters do.
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
|
||||
+86
-4
@@ -48,6 +48,12 @@ impl Row {
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
|
||||
/// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX
|
||||
/// reader bounds them (a native document can say anything).
|
||||
pub fn grid_after(&self) -> usize {
|
||||
(self.props.grid_after as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -97,7 +103,8 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
let row_cols =
|
||||
|r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after());
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
@@ -165,6 +172,54 @@ impl Table {
|
||||
self.rows.remove(r);
|
||||
}
|
||||
|
||||
/// Split the table before row `at`: the rows from `at` on move to the returned table, which
|
||||
/// has this table's properties and grid. A vertical merge that the split cuts ends in this
|
||||
/// table's last row and starts again in the new table's first row. A row content control
|
||||
/// around rows on both sides closes after this table's last row and opens again (without its
|
||||
/// `w:id`) before the new table's first row. Content controls that close after this table
|
||||
/// close after the new one.
|
||||
pub fn split_off(&mut self, at: usize) -> Table {
|
||||
let at = at.min(self.rows.len());
|
||||
let mut open = Vec::new();
|
||||
for row in self.rows.iter().take(at) {
|
||||
open.extend(row.controls.open.iter().cloned());
|
||||
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
|
||||
open.truncate(open.len() - close);
|
||||
}
|
||||
let mut tail = Table {
|
||||
props: self.props.clone(),
|
||||
grid: self.grid.clone(),
|
||||
rows: self.rows.split_off(at),
|
||||
controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) },
|
||||
};
|
||||
if let Some(last) = self.rows.last_mut() {
|
||||
last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX));
|
||||
for c in &mut last.cells {
|
||||
if c.props.vmerge == VMerge::Restart {
|
||||
c.props.vmerge = VMerge::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in &mut open {
|
||||
c.id = None;
|
||||
}
|
||||
if let Some(first) = tail.rows.first_mut() {
|
||||
let own = std::mem::replace(&mut first.controls.open, open);
|
||||
first.controls.open.extend(own);
|
||||
}
|
||||
let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len()))
|
||||
.filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue))
|
||||
.map(|c| (c, tail.grid_col(0, c)))
|
||||
.collect();
|
||||
for (c, g) in starts {
|
||||
let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
|
||||
if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
|
||||
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
|
||||
}
|
||||
}
|
||||
tail
|
||||
}
|
||||
|
||||
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
|
||||
pub fn insert_col(&mut self, g: usize) {
|
||||
if self.grid.len() >= MAX_COLS {
|
||||
@@ -193,7 +248,8 @@ impl Table {
|
||||
match idx {
|
||||
Some((i, true)) => {
|
||||
if let Some(c) = row.cells.get_mut(i) {
|
||||
c.props.span += 1;
|
||||
// From the clamped span: a `.json` file can carry any `u32` (#3637).
|
||||
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
|
||||
}
|
||||
}
|
||||
Some((i, false)) => {
|
||||
@@ -223,7 +279,7 @@ impl Table {
|
||||
for (i, c) in row.cells.iter_mut().enumerate() {
|
||||
if g < x + c.span() {
|
||||
if c.span() > 1 {
|
||||
c.props.span -= 1;
|
||||
c.props.span = (c.span() - 1) as u32;
|
||||
} else {
|
||||
rm = Some(i);
|
||||
}
|
||||
@@ -247,7 +303,7 @@ impl Table {
|
||||
let removed: Vec<Cell> = row.cells.drain(c0 + 1..=c1).collect();
|
||||
if let Some(first) = row.cells.get_mut(c0) {
|
||||
for c in removed {
|
||||
first.props.span += c.props.span.max(1);
|
||||
first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32;
|
||||
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
|
||||
first.blocks.extend(nonempty);
|
||||
}
|
||||
@@ -549,6 +605,32 @@ mod tests {
|
||||
t.delete_col(99);
|
||||
}
|
||||
|
||||
/// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it
|
||||
/// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS.
|
||||
#[test]
|
||||
fn a_huge_span_from_a_file_does_not_overflow() {
|
||||
let huge = || {
|
||||
let mut t = Table::new(1, 2, 200.0);
|
||||
t.rows[0].cells[0].props.span = u32::MAX;
|
||||
t
|
||||
};
|
||||
// Column 1 is inside the spanning cell, so the cell widens.
|
||||
let mut t = huge();
|
||||
t.insert_col(1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.merge_right(0, 0, 1);
|
||||
assert_eq!(t.rows[0].cells.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.rows[0].cells[1].props.span = u32::MAX;
|
||||
t.merge_right(0, 0, 1);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
|
||||
let mut t = huge();
|
||||
t.delete_col(0);
|
||||
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_and_split() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
|
||||
//! a limit which leaves something out says so.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Capture;
|
||||
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static CAPTURE: Capture = Capture;
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
// Only this logger is ever installed in a test binary; a second call keeps it.
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
@@ -90,6 +90,45 @@ fn parts_and_media() {
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_media_add_in_linear_time() {
|
||||
// An import with tens of thousands of small distinct images (same length, differing only at
|
||||
// the end) used to compare each against every earlier one.
|
||||
let mut d = Document::new();
|
||||
let t = std::time::Instant::now();
|
||||
let n = 40_000u32;
|
||||
for i in 0..n {
|
||||
let mut bytes = vec![7u8; 256];
|
||||
bytes.extend(i.to_le_bytes());
|
||||
d.add_media(bytes, "png");
|
||||
}
|
||||
assert_eq!(d.media.len(), n as usize);
|
||||
// Duplicates still find their first copy, including media inserted directly.
|
||||
let mut first = vec![7u8; 256];
|
||||
first.extend(0u32.to_le_bytes());
|
||||
assert_eq!(d.add_media(first, "png"), "image1.png");
|
||||
d.media.insert("direct.png".into(), Arc::new(vec![9, 9]));
|
||||
assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png");
|
||||
// About half a second on a loaded machine; the old scan took over two minutes.
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
// The lookup cache isn't part of the document.
|
||||
let mut e = d.clone();
|
||||
e.media_index = MediaIndex::default();
|
||||
assert_eq!(d, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_keys_skip_taken_names_once() {
|
||||
let mut d = Document::new();
|
||||
// A file whose media already use the names add_media would pick next.
|
||||
for i in 2..2002u32 {
|
||||
d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec()));
|
||||
}
|
||||
assert_eq!(d.add_media(vec![0], "png"), "image2002.png");
|
||||
assert_eq!(d.add_media(vec![1], "png"), "image2003.png");
|
||||
assert_eq!(d.add_media(vec![2], "png"), "image2004.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ids_skip_taken_ones_instead_of_overflowing() {
|
||||
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
|
||||
@@ -141,6 +180,72 @@ fn clone_is_shallow() {
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
|
||||
}
|
||||
|
||||
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
|
||||
/// must see every change, and it must not call two different documents the same.
|
||||
#[test]
|
||||
fn same_content_sees_every_change() {
|
||||
let d = Document::from_text("Hello\nWorld");
|
||||
assert!(d.same_content(&d.clone()), "a copy is the same document");
|
||||
// The text changed.
|
||||
let mut e = d.clone();
|
||||
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
|
||||
assert!(!d.same_content(&e), "the text changed");
|
||||
// The formatting changed.
|
||||
let mut e = d.clone();
|
||||
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
e.settings.track_changes = true;
|
||||
assert!(!d.same_content(&e), "a setting changed");
|
||||
// The metadata changed.
|
||||
let mut e = d.clone();
|
||||
e.core.title = "Draft".into();
|
||||
assert!(!d.same_content(&e), "the title changed");
|
||||
// A part changed.
|
||||
let mut e = d.clone();
|
||||
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
|
||||
assert!(!d.same_content(&e), "a part was added");
|
||||
// A picture changed.
|
||||
let mut e = d.clone();
|
||||
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
|
||||
assert!(!d.same_content(&e), "a picture was added");
|
||||
// A revision was recorded.
|
||||
let mut e = d.clone();
|
||||
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
|
||||
assert!(!d.same_content(&e), "a revision was recorded");
|
||||
// A comment was added.
|
||||
let mut e = d.clone();
|
||||
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a comment was added");
|
||||
// A paragraph that was marked as changed but holds the same content is the same.
|
||||
let mut e = d.clone();
|
||||
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
|
||||
assert!(d.same_content(&e), "the paragraph only changed its counter");
|
||||
// A table copied for an edit: the same until a cell's content changes.
|
||||
let mut d = Document::from_text("Hello");
|
||||
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
|
||||
let mut e = d.clone();
|
||||
Arc::make_mut(&mut e.body[0]);
|
||||
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
|
||||
assert!(d.same_content(&e), "the table was only copied");
|
||||
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
|
||||
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
|
||||
assert!(!d.same_content(&e), "a cell's text changed");
|
||||
// A building block was added.
|
||||
let mut e = d.clone();
|
||||
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a building block was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let mut d = Document::from_text("Hello\nWorld");
|
||||
@@ -457,3 +562,92 @@ fn custom_props_set_get_remove() {
|
||||
assert!(!d.remove_custom_prop("other"));
|
||||
assert_eq!(d.custom_props.len(), 1);
|
||||
}
|
||||
|
||||
/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too).
|
||||
fn para_of_objects(objs: Vec<InlineObject>) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = para::OBJ.to_string().repeat(objs.len());
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = objs;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_walks_are_linear_in_the_paragraph() {
|
||||
// #3614: loops of object_offsets + object_at recounted the objects before each one, so a
|
||||
// paragraph of 40,000 markers took seconds per walk.
|
||||
let n = 40_000u32;
|
||||
let w = para::OBJ.len_utf8();
|
||||
let mut objs: Vec<InlineObject> = Vec::new();
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::BookmarkStart { name: format!("b{i}") });
|
||||
objs.push(fs("ADDIN x"));
|
||||
objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
// Ranges close in the order they opened (the worst case for a backwards search).
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::FieldEnd);
|
||||
objs.push(InlineObject::PermEnd { id: i });
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.bookmarks().len(), n as usize);
|
||||
assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize);
|
||||
let perms = d.perm_ranges();
|
||||
assert_eq!(perms.len(), n as usize);
|
||||
assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end");
|
||||
assert_eq!(d.text_box_anchor(7), None);
|
||||
// A caret before a long row of field ends moves past all of them.
|
||||
let first_end = 3 * n as usize * w;
|
||||
let p = d.past_field_ends(&Pos::body(0, first_end));
|
||||
assert_eq!(p.off, first_end + w);
|
||||
// Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test).
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
|
||||
let ends: Vec<InlineObject> = (0..n).map(|_| InlineObject::FieldEnd).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w);
|
||||
// Typing at the end of a row of block-level control starts.
|
||||
let block = ContentControl { block: true, ..Default::default() };
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))];
|
||||
let end = Pos::body(0, n as usize * w);
|
||||
assert_eq!(d.typing_pos(&end), end);
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_across_many_lone_control_markers_is_not_quadratic() {
|
||||
// A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000.
|
||||
let n = 3_000;
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect();
|
||||
let mut d = Document::from_text("before\n\nafter");
|
||||
d.body[1] = Arc::new(Block::Para(para_of_objects(starts)));
|
||||
let t = std::time::Instant::now();
|
||||
let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 3));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "befter");
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_markers_kept_are_sorted() {
|
||||
// `delete_range` groups the kept markers by paragraph and binary-searches each group, so
|
||||
// `kept_control_markers` must hand them over sorted and without repeats (review of #3874).
|
||||
let start = || InlineObject::ControlStart { control: Box::default() };
|
||||
let mut d = Document::from_text("before");
|
||||
// Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens…
|
||||
for i in 0..12 {
|
||||
let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] };
|
||||
d.body.push(Arc::new(Block::Para(para_of_objects(objs))));
|
||||
}
|
||||
d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default()))));
|
||||
let w = para::OBJ.len_utf8();
|
||||
for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] {
|
||||
let kept = d.kept_control_markers(&a, &b);
|
||||
assert!(!kept.is_empty(), "{a:?}..{b:?}");
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+34
-5
@@ -92,11 +92,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
|
||||
ShapeKind::Arrow => {
|
||||
// As drawn: the head is half the height long, the shaft 40% of the height thick.
|
||||
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
|
||||
vec![(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)]
|
||||
}
|
||||
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
|
||||
ShapeKind::Star => (0..10)
|
||||
.map(|i| {
|
||||
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
|
||||
@@ -126,6 +122,26 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
Some(pts)
|
||||
}
|
||||
|
||||
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
|
||||
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
|
||||
/// thickness long, the shaft 40% of the thickness thick.
|
||||
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||||
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
|
||||
let (len, thick) = if vertical { (h, w) } else { (w, h) };
|
||||
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
|
||||
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
|
||||
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
|
||||
right
|
||||
.iter()
|
||||
.map(|&(u, v)| match kind {
|
||||
ShapeKind::ArrowLeft => (1.0 - u, v),
|
||||
ShapeKind::ArrowUp => (v, 1.0 - u),
|
||||
ShapeKind::ArrowDown => (v, u),
|
||||
_ => (u, v),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Largest side of the grid [`alpha_outline`] looks at.
|
||||
pub const ALPHA_GRID: usize = 48;
|
||||
|
||||
@@ -214,6 +230,16 @@ mod tests {
|
||||
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
|
||||
}
|
||||
|
||||
/// #3919: the tip of each block arrow lies on the side it points to.
|
||||
#[test]
|
||||
fn arrow_outlines_point_the_right_way() {
|
||||
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
|
||||
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
|
||||
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
|
||||
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
|
||||
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_outlines_stay_in_the_frame() {
|
||||
for kind in [
|
||||
@@ -223,6 +249,9 @@ mod tests {
|
||||
ShapeKind::RightTriangle,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::ArrowLeft,
|
||||
ShapeKind::ArrowUp,
|
||||
ShapeKind::ArrowDown,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
] {
|
||||
|
||||
@@ -20,5 +20,6 @@ log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -90,8 +90,11 @@ pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation.
|
||||
/// The digest is part of the file format, not a choice made here: a stronger hash would
|
||||
/// make password-protected `.doc` files written by Word unreadable.
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha1::new();
|
||||
let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
@@ -292,8 +295,13 @@ impl Cipher {
|
||||
if hflags & H_EXTERNAL != 0 {
|
||||
return Err(unsupported("the document uses an external encryption provider"));
|
||||
}
|
||||
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
|
||||
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
|
||||
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
|
||||
if hflags & H_AES != 0 {
|
||||
return Err(unsupported("AES-encrypted .doc files are not supported"));
|
||||
return Err(unsupported(
|
||||
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
|
||||
));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
|
||||
@@ -81,12 +81,18 @@ pub(crate) fn anchors(
|
||||
) -> Vec<(u32, InlineObject)> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (cp, spa) in plc(table, pair, CB_SPA) {
|
||||
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
|
||||
let spid = i32_at(0) as u32;
|
||||
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
|
||||
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let Some(node) = shapes.get(&spid) else { continue };
|
||||
// Word anchors each shape once. A repeated spid would build another copy of the shape,
|
||||
// and of every member of a group, per anchor (#3710).
|
||||
if !seen.insert(spid) {
|
||||
continue;
|
||||
}
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
@@ -190,7 +196,10 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
5 => ShapeKind::Triangle,
|
||||
6 => ShapeKind::RightTriangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
13 | 69 => ShapeKind::Arrow,
|
||||
66 => ShapeKind::ArrowLeft,
|
||||
67 => ShapeKind::ArrowDown,
|
||||
68 => ShapeKind::ArrowUp,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
74 => ShapeKind::Heart,
|
||||
202 => ShapeKind::TextBox,
|
||||
|
||||
+191
-29
@@ -49,6 +49,91 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
/// copied strings left empty and floating objects dropped, each kind logged once a document
|
||||
/// ([`Work::log`]).
|
||||
const MAX_WORK: u64 = 64 << 20;
|
||||
|
||||
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Cost {
|
||||
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
|
||||
Formatting,
|
||||
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
|
||||
LinkTarget,
|
||||
/// A picture's alt text copied into an anchor: past it the picture has none.
|
||||
AltText,
|
||||
/// A floating object copied into its anchor: past it the object is left out.
|
||||
FloatingObject,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
|
||||
|
||||
/// What is left out, for the log.
|
||||
fn left_out(self) -> &'static str {
|
||||
match self {
|
||||
Cost::Formatting => "some text keeps only part of its formatting",
|
||||
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
|
||||
Cost::AltText => "some pictures are left without their alt text",
|
||||
Cost::FloatingObject => "some floating shapes and pictures are left out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
|
||||
/// the document has been read, [`Work::log`]).
|
||||
struct Work {
|
||||
left: std::cell::Cell<u64>,
|
||||
dropped: std::cell::Cell<[bool; 4]>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
fn new(budget: u64) -> Self {
|
||||
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
|
||||
}
|
||||
|
||||
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
|
||||
fn charge(&self, cost: u64, what: Cost) -> bool {
|
||||
match self.left.get().checked_sub(cost) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
let mut dropped = self.dropped.get();
|
||||
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
|
||||
*d = true;
|
||||
}
|
||||
self.dropped.set(dropped);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of work left out so far, in [`Cost::ALL`] order.
|
||||
fn dropped(&self) -> Vec<Cost> {
|
||||
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
|
||||
}
|
||||
|
||||
/// Logs one warning for each kind left out.
|
||||
fn log(&self) {
|
||||
let dropped = self.dropped();
|
||||
for c in &dropped {
|
||||
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
|
||||
}
|
||||
#[cfg(test)]
|
||||
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
|
||||
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
/// `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
|
||||
@@ -88,10 +173,16 @@ pub enum DocbinError {
|
||||
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
|
||||
/// anything that is not a readable Word 97-2003 file.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
|
||||
let Ok(mut comp) = open_container(bytes) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
}
|
||||
|
||||
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
|
||||
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
|
||||
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
|
||||
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
@@ -102,7 +193,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
@@ -168,13 +259,14 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
let ftn_base = fib.ccp.text;
|
||||
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
|
||||
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
|
||||
let mut pics = media::Pictures::default();
|
||||
let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len()));
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -190,6 +282,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
@@ -298,6 +391,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
work.log();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -334,6 +428,8 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -365,6 +461,14 @@ impl ParaBuild {
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += c.len_utf8();
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
|
||||
@@ -376,7 +480,7 @@ impl ParaBuild {
|
||||
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
|
||||
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
|
||||
/// really exists, not by the CP numbers a file claims.
|
||||
fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
let cp_end = cp_end.min(ctx.pieces.cp_end());
|
||||
let mut out = Vec::new();
|
||||
let mut pb = ParaBuild::default();
|
||||
@@ -385,6 +489,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
@@ -490,7 +595,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
f.depth -= 1;
|
||||
field = Some(f);
|
||||
} else {
|
||||
finish_field(&mut pb, f);
|
||||
finish_field(&mut pb, f, ctx.work);
|
||||
}
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
@@ -502,12 +607,15 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some(p) => {
|
||||
// Every anchor of a picture gets its own copy of the alt text; the
|
||||
// copies are charged to the work budget (#3709).
|
||||
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: p.key,
|
||||
media: p.key.clone(),
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt: p.alt,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
@@ -519,7 +627,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
// Each copy (a story can be walked more than once) is charged to the work budget
|
||||
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
|
||||
continue;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
@@ -532,16 +645,16 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()),
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -564,19 +677,44 @@ struct FieldBuild {
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
/// its members (#3710).
|
||||
fn object_cost(obj: &InlineObject) -> u64 {
|
||||
let shallow = std::mem::size_of::<InlineObject>() as u64;
|
||||
match obj {
|
||||
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
|
||||
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
|
||||
_ => shallow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
|
||||
/// field becomes one inline object with its cached result.
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
///
|
||||
/// The target is copied once per run of equally formatted result text, not per character,
|
||||
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
|
||||
/// result is kept as plain text (#3708).
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let instr = f.instr.trim().to_string();
|
||||
let result = f.sep.unwrap_or_default();
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
for (c, mut props) in result {
|
||||
props.link = Some(target.clone());
|
||||
pb.push(c, props);
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
@@ -702,19 +840,28 @@ fn num_of(ilfo: i32) -> Option<u32> {
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let default;
|
||||
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
}
|
||||
};
|
||||
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;
|
||||
@@ -739,8 +886,20 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) {
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
struct FmtMemo<'a> {
|
||||
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
|
||||
chpx: &'a [u8],
|
||||
prm: u16,
|
||||
style: Option<String>,
|
||||
props: CharProps,
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
|
||||
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
|
||||
match prm {
|
||||
PrmRef::None => {}
|
||||
PrmRef::Sprm0 { isprm, val } => {
|
||||
@@ -753,6 +912,9 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch
|
||||
}
|
||||
}
|
||||
PrmRef::Grpprl(Some(g)) => {
|
||||
if !work.charge(g.len() as u64, Cost::Formatting) {
|
||||
return;
|
||||
}
|
||||
for prl in sprm::iter(g) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(props, &prl, base, fonts);
|
||||
|
||||
+68
-26
@@ -17,6 +17,24 @@ const OFF_PICMID: usize = 28;
|
||||
const MAX_PICTURES: usize = 5_000;
|
||||
/// Most picture bytes one document copies out of the Data stream, all pictures together.
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
|
||||
/// per attempt whether it succeeds or not: retained media is capped separately, by
|
||||
/// `MAX_MEDIA_BYTES` (#3711, #3712).
|
||||
const MAX_DECODE_WORK: usize = 512 << 20;
|
||||
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
|
||||
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
|
||||
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
|
||||
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
|
||||
/// bomb (ratios around 1e6) still hits the cap.
|
||||
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
|
||||
/// The most the decode budget grows to, whatever the input.
|
||||
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
|
||||
|
||||
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
|
||||
/// than `floor`.
|
||||
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
|
||||
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
|
||||
}
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
@@ -28,11 +46,17 @@ pub(crate) struct Pictures {
|
||||
by_fc: HashMap<u32, Option<Inline>>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// (stream, foDelay, record length) of a BLIP record outside the store → media key: store
|
||||
/// entries and inline pictures that alias one record share one decode (`None`: failed, not
|
||||
/// retried). The stream is told apart by its address.
|
||||
by_delay: HashMap<(usize, usize, usize), Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
|
||||
pub(crate) limit: usize,
|
||||
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
|
||||
pub(crate) work: officeart::Decode,
|
||||
/// Anchors whose picture was dropped (only the first few are logged).
|
||||
dropped: usize,
|
||||
/// Pictures kept as placeholders (formats we can't draw).
|
||||
@@ -44,9 +68,11 @@ impl Default for Pictures {
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
by_delay: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
work: officeart::Decode::new(MAX_DECODE_WORK),
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
@@ -64,19 +90,24 @@ pub(crate) struct Inline {
|
||||
}
|
||||
|
||||
impl Pictures {
|
||||
/// An empty store whose decode budget scales with the `input` stream bytes (WordDocument
|
||||
/// plus Data) the pictures can come from.
|
||||
pub(crate) fn for_input(input: usize) -> Self {
|
||||
Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() }
|
||||
}
|
||||
|
||||
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
|
||||
/// `None` when the header is malformed, the picture can't be read, or a picture
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
if let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return hit.clone();
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
|
||||
if !self.by_fc.contains_key(&fc_pic) {
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic);
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
pic
|
||||
self.by_fc.get(&fc_pic)?.as_ref()
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
@@ -86,16 +117,13 @@ impl Pictures {
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, room)? {
|
||||
match locate(data, fc_pic, self)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
|
||||
}
|
||||
Found::Shape { w, h, blip, props } => {
|
||||
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
|
||||
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
|
||||
}
|
||||
Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +137,26 @@ impl Pictures {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
let key = self.decode_bse(fbse, delay);
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
|
||||
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
|
||||
let (rec, at) = officeart::fbse_record(fbse, delay)?;
|
||||
let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used));
|
||||
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
|
||||
return hit.clone();
|
||||
}
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
if let Some(at) = at {
|
||||
self.by_delay.insert(at, key.clone());
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// Add picture bytes as a new media entry, within the byte cap.
|
||||
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
|
||||
@@ -151,7 +193,7 @@ struct Located {
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
|
||||
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
@@ -160,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064;
|
||||
const MM_SHAPEFILE: u16 = 0x0066;
|
||||
|
||||
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
|
||||
/// a shape's picture is decoded only up to `room` bytes.
|
||||
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
/// the BLIP is decoded once per record and charged to the decode budget.
|
||||
fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
let base = fc_pic as usize;
|
||||
let pic = data.get(base..)?;
|
||||
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
|
||||
@@ -180,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
|
||||
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
|
||||
// The goal size is before cropping; the crop fractions take their share off it.
|
||||
let [cl, ct, cr, cb] = props.crop;
|
||||
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
|
||||
return Some(Found::Shape { w, h, blip, props });
|
||||
return Some(Found::Shape { w, h, key, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
@@ -197,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
|
||||
/// the BLIP decodes to.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded
|
||||
/// once per record, however many pictures (and FBSE sizes) name it.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option<String>, officeart::Props)> {
|
||||
let mut budget = 10_000usize;
|
||||
let recs = officeart::children(buf, &mut budget);
|
||||
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
|
||||
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
|
||||
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
|
||||
let blip = officeart::fbse_blip(fbse, data, room)?;
|
||||
Some((blip, sp.props))
|
||||
let key = pics.decode_bse(fbse, data);
|
||||
Some((key, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
||||
+90
-14
@@ -89,29 +89,105 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32,
|
||||
out
|
||||
}
|
||||
|
||||
/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data).
|
||||
/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not
|
||||
/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped.
|
||||
/// Names are kept to 40 characters, but the reader always advances by the stored length.
|
||||
fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option<Vec<String>> {
|
||||
let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?;
|
||||
if b.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let c_data = u16::from_le_bytes([b[2], b[3]]) as usize;
|
||||
let extended = b.get(0..2)? == [0xFF, 0xFF];
|
||||
let head = if extended { 2 } else { 0 };
|
||||
let h = b.get(head..head + 4)?;
|
||||
let c_data = u16::from_le_bytes([h[0], h[1]]) as usize;
|
||||
let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize;
|
||||
if c_data > MAX {
|
||||
return None;
|
||||
}
|
||||
let mut at = 6usize;
|
||||
let mut at = head + 4;
|
||||
let mut out = Vec::with_capacity(c_data);
|
||||
for _ in 0..c_data {
|
||||
let cch = match b.get(at..at + 2) {
|
||||
Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize,
|
||||
None => break,
|
||||
let (cch, unit) = if extended {
|
||||
match b.get(at..at + 2) {
|
||||
Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2),
|
||||
None => break,
|
||||
}
|
||||
} else {
|
||||
match b.get(at) {
|
||||
Some(&x) => (x as usize, 1),
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
at += unit;
|
||||
let Some(chars) = b.get(at..at + cch * unit) else { break };
|
||||
at += cch * unit;
|
||||
// The string's extra data must be there too, or the table is cut short.
|
||||
if b.get(at..at + cb_extra).is_none() {
|
||||
break;
|
||||
}
|
||||
at += cb_extra;
|
||||
let s: String = if extended {
|
||||
chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect()
|
||||
} else {
|
||||
chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect()
|
||||
};
|
||||
at += 2;
|
||||
let cch = cch.min(40);
|
||||
let chars = b.get(at..at + cch * 2)?;
|
||||
at += cch * 2;
|
||||
let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect();
|
||||
out.push(s);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sttb_strings;
|
||||
|
||||
fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xFF];
|
||||
b.extend_from_slice(&(names.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&(extra.len() as u16).to_le_bytes());
|
||||
for n in names {
|
||||
let units: Vec<u16> = n.encode_utf16().collect();
|
||||
b.extend_from_slice(&(units.len() as u16).to_le_bytes());
|
||||
for u in units {
|
||||
b.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(extra);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_bookmark_name_does_not_desync_table() {
|
||||
let long = "A".repeat(50);
|
||||
let b = ext_sttb(&[&long, "bm2"], &[]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sttb_extra_data_is_skipped() {
|
||||
let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_without_its_extra_data_is_dropped() {
|
||||
let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
b.truncate(b.len() - 2);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_extended_sttb_reads_byte_strings() {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
for n in ["one", "caf\u{e9}"] {
|
||||
let bytes: Vec<u8> = n.chars().map(|c| c as u8).collect();
|
||||
b.push(bytes.len() as u8);
|
||||
b.extend_from_slice(&bytes);
|
||||
b.extend_from_slice(&[9, 9]);
|
||||
}
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,23 +112,65 @@ pub(crate) struct Blip {
|
||||
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
|
||||
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
|
||||
/// inline pictures). `limit` caps the bytes produced.
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let (rec, _) = fbse_record(fbse, delay)?;
|
||||
blip(&rec, limit, work)
|
||||
}
|
||||
|
||||
/// The document's decode budget: bytes of inflating and pixel decoding left, and how many
|
||||
/// attempts it has refused (only the first few are logged).
|
||||
pub(crate) struct Decode {
|
||||
pub(crate) left: usize,
|
||||
refused: usize,
|
||||
}
|
||||
|
||||
impl Decode {
|
||||
pub(crate) fn new(left: usize) -> Self {
|
||||
Decode { left, refused: 0 }
|
||||
}
|
||||
|
||||
/// Take `cost` off the budget; `false` (and a log line) when it doesn't fit.
|
||||
fn charge(&mut self, cost: usize, what: &str) -> bool {
|
||||
match self.left.checked_sub(cost) {
|
||||
Some(left) if cost > 0 => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.refused < 10 {
|
||||
log::warn!(
|
||||
"docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded",
|
||||
self.left
|
||||
);
|
||||
}
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
|
||||
/// its (`foDelay`, record length) location is returned too (entries can share one record,
|
||||
/// whatever sizes their FBSEs claim: the record's own header is what counts).
|
||||
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
|
||||
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
|
||||
let size = u32_at(fbse, 20)? as usize;
|
||||
let fo_delay = u32_at(fbse, 28)? as usize;
|
||||
let cb_name = *fbse.get(33)? as usize;
|
||||
let at = 36usize.checked_add(cb_name)?;
|
||||
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
|
||||
return blip(&rec, limit);
|
||||
return Some((rec, None));
|
||||
}
|
||||
// Not embedded: the record sits in the delay stream.
|
||||
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
|
||||
let (rec, _) = record(delay.get(fo_delay..end)?)?;
|
||||
blip(&rec, limit)
|
||||
let (rec, used) = record(delay.get(fo_delay..)?)?;
|
||||
Some((rec, Some((fo_delay, used))))
|
||||
}
|
||||
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
|
||||
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
|
||||
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
// Each type has two instance values; the odd one carries a second 16-byte UID.
|
||||
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
|
||||
match rec.typ {
|
||||
@@ -141,7 +183,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data, limit),
|
||||
_ => dib_to_png(data, limit, work),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
@@ -155,6 +197,11 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
|
||||
// The compressed stream is read whole whatever it inflates to: a record that
|
||||
// claims to inflate to nothing still costs its input (#3871).
|
||||
if !work.charge(want.max(raw.len()), "metafile") {
|
||||
return None;
|
||||
}
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
@@ -177,8 +224,9 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
|
||||
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the
|
||||
/// BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
@@ -197,12 +245,30 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
|
||||
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
|
||||
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
|
||||
// The declared size is what decoding allocates, however small the (RLE) data is.
|
||||
let (w, h) = if header == 12 {
|
||||
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
|
||||
} else {
|
||||
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
|
||||
};
|
||||
let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX);
|
||||
// Copying the DIB into a BMP is work too, however few pixels it declares: it is charged
|
||||
// before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP
|
||||
// bytes as a placeholder, if the copy fits.
|
||||
let decode = work.charge(cost.max(dib.len()), "bitmap");
|
||||
if !decode && !work.charge(dib.len(), "bitmap") {
|
||||
return None;
|
||||
}
|
||||
let mut bmp = Vec::with_capacity(file_size);
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
|
||||
if !decode {
|
||||
return placeholder(bmp);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
let mut limits = image::Limits::default();
|
||||
@@ -217,7 +283,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
{
|
||||
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
_ => placeholder(bmp),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,16 @@ impl PieceTable {
|
||||
}
|
||||
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
|
||||
} else {
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
|
||||
+179
-4
@@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() {
|
||||
let data: Vec<u8> = [a.clone(), b].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.limit = 1000;
|
||||
let first = pics.get(&data, 0).expect("first fits");
|
||||
let first = pics.get(&data, 0).cloned().expect("first fits");
|
||||
// Same location again: no new bytes.
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone()));
|
||||
// A second distinct picture would exceed the cap.
|
||||
assert!(pics.get(&data, a.len() as u32).is_none());
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
@@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() {
|
||||
assert!(pics.get(&small, 0).is_none());
|
||||
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
|
||||
let at = big.len() as u32;
|
||||
let fits = pics.get(&data, at).expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
|
||||
let fits = pics.get(&data, at).cloned().expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key));
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
// Filling the cap exactly works; after that, a place never tried before isn't read.
|
||||
let rest = picf_with_png(700);
|
||||
@@ -2392,3 +2392,178 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
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)));
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
|
||||
#[test]
|
||||
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
|
||||
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
|
||||
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
|
||||
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
|
||||
assert!(!super::is_encrypted(&f));
|
||||
// A file cfb wrote itself passes.
|
||||
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
|
||||
}
|
||||
|
||||
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
|
||||
/// an `ObjectPool` storage (where Word keeps embedded objects).
|
||||
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
|
||||
for (name, data) in streams {
|
||||
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.create_storage("/ObjectPool").expect("cfb storage");
|
||||
for (name, data) in extra {
|
||||
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
|
||||
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
|
||||
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
|
||||
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
|
||||
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
|
||||
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
|
||||
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
|
||||
#[test]
|
||||
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
|
||||
let doc = struct_doc(&structured_spec("Hello world\r", 12));
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
|
||||
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
|
||||
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
|
||||
let mut stream = |name: &str| {
|
||||
let mut v = Vec::new();
|
||||
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
|
||||
v
|
||||
};
|
||||
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
|
||||
let summary = vec![0xFEu8; 4096];
|
||||
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
|
||||
let small = data(200_000);
|
||||
let big = data(8 << 20);
|
||||
let ole = data(70_000);
|
||||
let files = [
|
||||
("as built", doc.clone()),
|
||||
(
|
||||
"version 3",
|
||||
cfb_file_in(
|
||||
cfb::Version::V3,
|
||||
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
|
||||
&[("_1234567890", &ole)],
|
||||
),
|
||||
),
|
||||
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
|
||||
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
|
||||
];
|
||||
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
|
||||
for (what, f) in &files {
|
||||
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
|
||||
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
|
||||
/// character. It is now copied once per run.
|
||||
#[test]
|
||||
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
let n = 250_000;
|
||||
let target = "x".repeat(n);
|
||||
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
|
||||
let spec = 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("para");
|
||||
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
|
||||
}
|
||||
|
||||
/// #3708: a result whose formatting changes every character pays for a target copy per run
|
||||
/// out of the work budget; past it the text stays, unlinked.
|
||||
#[test]
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
super::finish_field(&mut pb, f, &budget);
|
||||
assert_eq!(pb.text.len(), 1000, "all text kept");
|
||||
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
|
||||
assert!(linked <= 10_000, "linked bytes {linked}");
|
||||
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
|
||||
assert_eq!(budget.left.get(), 0);
|
||||
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
|
||||
}
|
||||
|
||||
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
|
||||
/// logged once per kind when the document has been read; nothing is noted while it lasts.
|
||||
#[test]
|
||||
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
|
||||
use super::Cost::*;
|
||||
let work = super::Work::new(10);
|
||||
assert!(work.charge(10, AltText));
|
||||
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
|
||||
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
|
||||
for _ in 0..3 {
|
||||
assert!(!work.charge(1, FloatingObject));
|
||||
}
|
||||
assert!(!work.charge(1, AltText));
|
||||
assert_eq!(work.dropped(), [AltText, FloatingObject]);
|
||||
work.log();
|
||||
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
|
||||
// A document within the budget leaves nothing out.
|
||||
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
|
||||
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
|
||||
}
|
||||
|
||||
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
|
||||
/// It is now resolved once per formatting change.
|
||||
#[test]
|
||||
fn a_large_prc_is_interpreted_once_per_formatting_change() {
|
||||
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
|
||||
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
|
||||
let spec = RichSpec {
|
||||
text,
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Times New Roman"],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new())],
|
||||
};
|
||||
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&bytes).expect("opens");
|
||||
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p = para0(&doc);
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
|
||||
}
|
||||
|
||||
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
|
||||
/// is skipped (the text keeps its CHPX formatting).
|
||||
#[test]
|
||||
fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let g = grpprl(&[(0x0835, &[0x01])]);
|
||||
let work = super::Work::new(g.len() as u64 + 1);
|
||||
let base = CharProps::default();
|
||||
let mut first = CharProps::default();
|
||||
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(first.bold, Some(true));
|
||||
let mut second = CharProps::default();
|
||||
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(second.bold, None);
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ fn fbse_png(png: &[u8]) -> Vec<u8> {
|
||||
|
||||
/// UTF-16LE, NUL-terminated (a complex string property).
|
||||
fn wz(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
|
||||
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
|
||||
@@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
|
||||
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (dib_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes");
|
||||
assert_eq!(blip.ext, "png");
|
||||
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
|
||||
@@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
|
||||
let (wmf_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates");
|
||||
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
|
||||
}
|
||||
|
||||
@@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() {
|
||||
hdr.extend_from_slice(&[0, 0]);
|
||||
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
assert!(officeart::blip(&emf, 1 << 20).is_none());
|
||||
assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -322,13 +322,296 @@ fn dib_conversion_caps_header_and_encoded_bytes() {
|
||||
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (record, _) = officeart::record(&record).unwrap();
|
||||
let bmp_size = 14 + dib.len();
|
||||
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
|
||||
assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly");
|
||||
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
|
||||
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
|
||||
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
|
||||
let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits");
|
||||
assert_eq!((normal.ext, normal.placeholder), ("png", false));
|
||||
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
|
||||
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
|
||||
}
|
||||
|
||||
/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096
|
||||
/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty.
|
||||
#[test]
|
||||
fn alt_text_copies_across_many_anchors_are_bounded() {
|
||||
let alt: String = "€".repeat(4096); // 12 KB as UTF-8
|
||||
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]);
|
||||
let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat());
|
||||
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str());
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect();
|
||||
assert_eq!(alts.len(), 8000, "every anchor keeps its picture");
|
||||
assert_eq!(alts.first().copied(), Some(alt.as_str()));
|
||||
let total: usize = alts.iter().map(|a| a.len()).sum();
|
||||
assert!(total <= 64 << 20, "alt bytes {total}");
|
||||
assert_eq!(alts.last().copied(), Some(""));
|
||||
// Leaving it out is logged, once (#3871 review).
|
||||
assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]);
|
||||
}
|
||||
|
||||
/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is
|
||||
/// anchored once; repeated spids are skipped.
|
||||
#[test]
|
||||
fn a_group_named_by_many_anchors_is_built_once() {
|
||||
let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>());
|
||||
let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>())]);
|
||||
let members: Vec<Vec<u8>> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect();
|
||||
let group = container(0xF003, &[vec![head], members].concat());
|
||||
let art = art_content(&[], &[group]);
|
||||
let n = 5000u32;
|
||||
let cps: Vec<u32> = (1..=n + 1).collect();
|
||||
let spas: Vec<u8> = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect();
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) };
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
// About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry,
|
||||
// 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]).
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let groups: Vec<usize> =
|
||||
p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect();
|
||||
assert_eq!(groups, [1000]);
|
||||
}
|
||||
|
||||
/// #3710: a floating picture's copy is charged its alt text too, and a group every member's.
|
||||
#[test]
|
||||
fn a_floating_copy_is_charged_its_alt_text() {
|
||||
let image = |alt: String| InlineObject::Image {
|
||||
media: String::new(),
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let bare = crate::object_cost(&image(String::new()));
|
||||
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
|
||||
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let group = InlineObject::Group {
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
float: Default::default(),
|
||||
ch_w: 1.0,
|
||||
ch_h: 1.0,
|
||||
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
|
||||
canvas: None,
|
||||
};
|
||||
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
|
||||
}
|
||||
|
||||
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
|
||||
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = size.to_le_bytes().to_vec();
|
||||
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
|
||||
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat())
|
||||
}
|
||||
|
||||
/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream.
|
||||
fn delayed_fbse(size: usize, fo_delay: usize) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes());
|
||||
fbse
|
||||
}
|
||||
|
||||
/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again.
|
||||
/// Now the record is decoded once and every entry shares its media key.
|
||||
#[test]
|
||||
fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
|
||||
let wmf = vec![0u8; 1 << 20];
|
||||
let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6));
|
||||
let fbse = delayed_fbse(delay.len(), 0);
|
||||
let store: Vec<&[u8]> = vec![&fbse; 5_000];
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(8 << 20);
|
||||
let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect();
|
||||
assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture");
|
||||
assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once");
|
||||
}
|
||||
|
||||
/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so
|
||||
/// a document of them stops decoding once the work budget is spent.
|
||||
#[test]
|
||||
fn failing_metafile_inflations_spend_the_decode_budget() {
|
||||
let records: Vec<Vec<u8>> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect();
|
||||
let delay = records.concat();
|
||||
let mut at = 0;
|
||||
let fbses: Vec<Vec<u8>> = records
|
||||
.iter()
|
||||
.map(|r| {
|
||||
at += r.len();
|
||||
delayed_fbse(r.len(), at - r.len())
|
||||
})
|
||||
.collect();
|
||||
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(3 << 20);
|
||||
for pib in 1..=store.len() as u32 {
|
||||
assert!(pics.bse(pib, &store, &delay).is_none());
|
||||
}
|
||||
assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried");
|
||||
}
|
||||
|
||||
/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but
|
||||
/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded.
|
||||
#[test]
|
||||
fn dib_decoding_is_charged_its_declared_pixels() {
|
||||
let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat());
|
||||
let one = blip_of(&one_pixel_dib());
|
||||
let (one, _) = officeart::record(&one).unwrap();
|
||||
let mut work = officeart::Decode::new(3);
|
||||
assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied");
|
||||
assert_eq!(work.left, 3, "nothing decoded, nothing charged");
|
||||
let dib_len = one.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(dib_len);
|
||||
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false)));
|
||||
assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel");
|
||||
|
||||
// 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes.
|
||||
let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
big.extend_from_slice(&1u16.to_le_bytes());
|
||||
big.extend_from_slice(&8u16.to_le_bytes());
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8
|
||||
big.extend_from_slice(&[0u8; 12]);
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour
|
||||
big.extend_from_slice(&[0u8; 4]); // clrImportant
|
||||
big.extend_from_slice(&[0u8; 4]); // the palette colour
|
||||
big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap
|
||||
let big = blip_of(&big);
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let mut work = officeart::Decode::new(512 << 20);
|
||||
assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
|
||||
assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels");
|
||||
}
|
||||
|
||||
/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`.
|
||||
fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = vec![0u8; 34];
|
||||
hdr[0..4].copy_from_slice(&cb_size.to_le_bytes());
|
||||
hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes());
|
||||
hdr[32] = 0; // compressed
|
||||
hdr[33] = 0xFE;
|
||||
rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to.
|
||||
#[test]
|
||||
fn compressed_metafile_input_is_charged_to_the_decode_budget() {
|
||||
let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same
|
||||
let r = emf_blip(1, &raw);
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to");
|
||||
// Ten more of them exhaust the budget; none is inflated past it.
|
||||
let mut work = officeart::Decode::new(3 << 20);
|
||||
for _ in 0..10 {
|
||||
let _ = officeart::blip(&emf, 64 << 20, &mut work);
|
||||
}
|
||||
assert_eq!(work.left, 0, "three attempts fit, the rest are refused");
|
||||
}
|
||||
|
||||
/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the
|
||||
/// FBSE claims `size` bytes.
|
||||
fn inline_pic(fo_delay: u32, size: u32) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&size.to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes());
|
||||
let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat();
|
||||
let mut picf = vec![0u8; 0x44];
|
||||
picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes());
|
||||
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
|
||||
picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE
|
||||
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
|
||||
picf[30..32].copy_from_slice(&1440i16.to_le_bytes());
|
||||
[picf, body].concat()
|
||||
}
|
||||
|
||||
/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes
|
||||
/// their FBSEs claim and wherever their PICFs sit.
|
||||
#[test]
|
||||
fn inline_pictures_aliasing_one_blip_record_are_decoded_once() {
|
||||
let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]);
|
||||
let a = inline_pic(0, 70_000);
|
||||
let b = inline_pic(0, 80_000);
|
||||
let c = inline_pic(0, 90_000);
|
||||
let blip_at = (a.len() + b.len() + c.len()) as u32;
|
||||
let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000));
|
||||
let data = [a.clone(), b.clone(), c, blip].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
let start = pics.work.left;
|
||||
let _ = pics.get(&data, 0);
|
||||
let _ = pics.get(&data, a.len() as u32);
|
||||
let _ = pics.get(&data, (a.len() + b.len()) as u32);
|
||||
assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge");
|
||||
}
|
||||
|
||||
/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data.
|
||||
fn dib_1bpp(side: u32, bytes: usize) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[0..4].copy_from_slice(&40u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&side.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&side.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&1u16.to_le_bytes());
|
||||
d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette
|
||||
d.extend(std::iter::repeat_n(0u8, bytes));
|
||||
d
|
||||
}
|
||||
|
||||
fn dib_record(dib: &[u8]) -> Vec<u8> {
|
||||
rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the decode budget scales with the input, so a document of many legitimate
|
||||
/// large scans decodes them all; a high-ratio set is still capped.
|
||||
#[test]
|
||||
fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() {
|
||||
let floor = 1 << 20;
|
||||
// 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each.
|
||||
let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8));
|
||||
let (rec_real, _) = officeart::record(&real).unwrap();
|
||||
let input = 20 * real.len();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(input, floor));
|
||||
for i in 0..20 {
|
||||
let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap();
|
||||
assert!(!b.placeholder, "scan {i} decodes");
|
||||
}
|
||||
// Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded.
|
||||
let bomb = dib_record(&dib_1bpp(20_000, 16));
|
||||
let (rec_bomb, _) = officeart::record(&bomb).unwrap();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor));
|
||||
for _ in 0..20 {
|
||||
assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder);
|
||||
}
|
||||
assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented");
|
||||
}
|
||||
|
||||
/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels
|
||||
/// the header declares; with the budget spent nothing is copied at all.
|
||||
#[test]
|
||||
fn a_dib_with_a_large_tail_is_charged_for_its_copy() {
|
||||
let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let dib_len = big.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder);
|
||||
assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel");
|
||||
// Room for the copy: decoded. Not even that: nothing is copied.
|
||||
let mut work = officeart::Decode::new(dib_len + 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_some());
|
||||
let mut work = officeart::Decode::new(dib_len - 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, dib_len - 1);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ fn unsupported_variants_say_so() {
|
||||
let (h, k) = capi_header("pw", 128, hflags);
|
||||
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
|
||||
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
|
||||
if hflags & 0x20 != 0 {
|
||||
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
|
||||
}
|
||||
}
|
||||
let (mut h, k) = rc4_header("pw");
|
||||
h[0] = 9;
|
||||
|
||||
@@ -31,6 +31,7 @@ zeroize = { workspace = true }
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
|
||||
//! the same markup, so a chart with anything the model leaves out stays as it was read.
|
||||
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
|
||||
use wordcraft_doc::graphic::GraphicItem;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -23,9 +23,21 @@ pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/
|
||||
const CAT_AX: &str = "500000001";
|
||||
const VAL_AX: &str = "500000002";
|
||||
|
||||
/// The chart part for `spec`: a complete XML document. `spec` should be sanitized
|
||||
/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way.
|
||||
/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves
|
||||
/// it (a value that isn't finite is a gap).
|
||||
pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
chart_part(spec).0
|
||||
}
|
||||
|
||||
/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it.
|
||||
pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) {
|
||||
let kept = bounded(spec);
|
||||
let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
|
||||
let cut = kept.series.len() < spec.series.len() || kept.points() < points;
|
||||
(write_chart(&kept), cut)
|
||||
}
|
||||
|
||||
fn write_chart(spec: &ChartSpec) -> String {
|
||||
let mut w = W::new();
|
||||
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
|
||||
w.empty("c:roundedCorners", &[("val", "0")]);
|
||||
@@ -79,6 +91,26 @@ pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
w.s
|
||||
}
|
||||
|
||||
/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went
|
||||
/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would
|
||||
/// multiply (#3648). Only what the limits keep is copied before sanitizing.
|
||||
fn bounded(spec: &ChartSpec) -> ChartSpec {
|
||||
ChartSpec {
|
||||
kind: spec.kind,
|
||||
categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
series: spec
|
||||
.series
|
||||
.iter()
|
||||
.take(MAX_SERIES)
|
||||
.map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() })
|
||||
.collect(),
|
||||
title: spec.title.clone(),
|
||||
legend: spec.legend,
|
||||
data_labels: spec.data_labels,
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
|
||||
/// turn). Empty when the chart has no series.
|
||||
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
@@ -421,6 +453,27 @@ mod tests {
|
||||
e.els().map(|c| c.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_past_the_limits_is_written_within_them() {
|
||||
// A model deserialised as is (a `.json` document) never went through `sanitize`, and every
|
||||
// series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648).
|
||||
use wordcraft_doc::chart::MAX_CELLS;
|
||||
let spec = ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: (0..4500).map(|i| i.to_string()).collect(),
|
||||
series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
data_labels: false,
|
||||
};
|
||||
let x = chart_xml(&spec);
|
||||
let series = x.matches("<c:ser>").count();
|
||||
let points = x.matches("<c:pt ").count();
|
||||
assert!((1..=MAX_SERIES).contains(&series), "{series} series");
|
||||
assert!(points <= 2 * MAX_CELLS, "{points} points");
|
||||
assert!(!x.contains(&format!("<c:pt idx=\"{MAX_POINTS}\"")), "past the point limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_reads_back_as_itself() {
|
||||
for kind in ChartType::ALL {
|
||||
|
||||
+406
-20
@@ -9,6 +9,10 @@
|
||||
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
|
||||
//! hasn't been damaged or tampered with.
|
||||
//!
|
||||
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
|
||||
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
|
||||
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
|
||||
//!
|
||||
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
|
||||
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
|
||||
//! when dropped ([`Zeroizing`]) and never logged.
|
||||
@@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
|
||||
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
|
||||
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
|
||||
|
||||
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
|
||||
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
|
||||
const F_CRYPTO_API: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
const F_AES: u32 = 0x20;
|
||||
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
|
||||
const ALG_AES_128: u32 = 0x660E;
|
||||
const ALG_AES_192: u32 = 0x660F;
|
||||
const ALG_AES_256: u32 = 0x6610;
|
||||
const ALG_SHA1: u32 = 0x8004;
|
||||
const PROV_RSA_AES: u32 = 0x18;
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
const SHA1_SIZE: usize = 20;
|
||||
|
||||
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
|
||||
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
@@ -56,7 +74,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor
|
||||
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
|
||||
/// `EncryptedPackage` streams) rather than a zip?
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
match cfb::CompoundFile::open(Cursor::new(bytes)) {
|
||||
@@ -69,9 +87,13 @@ pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
|
||||
/// doesn't fit gives [`DocxError::WrongPassword`].
|
||||
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
|
||||
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
|
||||
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
|
||||
return decrypt_standard(&info, &package, password);
|
||||
}
|
||||
let info = parse_info(&info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let pe = &info.password;
|
||||
@@ -92,17 +114,18 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
|
||||
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
|
||||
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
|
||||
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
|
||||
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
|
||||
if let Some((enc_key, enc_value)) = &info.integrity {
|
||||
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
|
||||
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
|
||||
// anyway, so the padded key gives the same HMAC.
|
||||
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
|
||||
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
|
||||
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
|
||||
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
|
||||
return Err(bad("the document is damaged (its integrity check failed)"));
|
||||
}
|
||||
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required:
|
||||
// a file that could drop it would let altered ciphertext, or an altered StreamSize, through
|
||||
// unauthenticated (`parse_info` refuses a file without it).
|
||||
let (enc_key, enc_value) = &info.integrity;
|
||||
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
|
||||
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
|
||||
// anyway, so the padded key gives the same HMAC.
|
||||
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
|
||||
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
|
||||
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
|
||||
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
|
||||
return Err(bad("the document is damaged (its integrity check failed)"));
|
||||
}
|
||||
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
|
||||
let size = package
|
||||
@@ -124,6 +147,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
|
||||
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
|
||||
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
let s = parse_standard(info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let key = standard_key(s.salt, password, s.key_bytes);
|
||||
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
|
||||
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
|
||||
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
|
||||
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
|
||||
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
|
||||
return Err(DocxError::WrongPassword);
|
||||
}
|
||||
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
|
||||
let size = package
|
||||
.get(..8)
|
||||
.and_then(|b| <[u8; 8]>::try_from(b).ok())
|
||||
.map(u64::from_le_bytes)
|
||||
.ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let data = package.get(8..).unwrap_or(&[]);
|
||||
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
|
||||
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let mut out = ecb(&key, data, false)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
|
||||
/// and encrypted values.
|
||||
struct Standard<'a> {
|
||||
key_bytes: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
|
||||
let short = || bad("EncryptionInfo is truncated");
|
||||
let u32_at = |at: usize| -> Result<u32, DocxError> {
|
||||
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
|
||||
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
|
||||
};
|
||||
let flags = u32_at(4)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
|
||||
}
|
||||
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
|
||||
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
|
||||
}
|
||||
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
|
||||
// reserved words, then the CSP name, which isn't needed.
|
||||
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
|
||||
if header_size < 32 {
|
||||
return Err(bad("the encryption header is too short"));
|
||||
}
|
||||
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
|
||||
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
|
||||
let bits = match u32_at(20)? {
|
||||
0 | ALG_AES_128 => 128,
|
||||
ALG_AES_192 => 192,
|
||||
ALG_AES_256 => 256,
|
||||
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
|
||||
};
|
||||
let hash_id = u32_at(24)?;
|
||||
if !matches!(hash_id, 0 | ALG_SHA1) {
|
||||
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
|
||||
}
|
||||
if u32_at(28)? != bits {
|
||||
return Err(bad("the key size doesn't match the encryption algorithm"));
|
||||
}
|
||||
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
|
||||
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
|
||||
if u32_at(verifier_at)? != SALT as u32 {
|
||||
return Err(bad("the salt isn't 16 bytes"));
|
||||
}
|
||||
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
|
||||
let salt = field(4, SALT)?;
|
||||
let verifier = field(4 + SALT, BLOCK)?;
|
||||
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
|
||||
return Err(bad("the password verifier hash isn't SHA-1"));
|
||||
}
|
||||
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
|
||||
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
|
||||
/// count), as Word's "Encrypt with Password" does.
|
||||
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
|
||||
@@ -212,6 +321,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
|
||||
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
|
||||
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
|
||||
/// [`decrypt`] reads.
|
||||
#[doc(hidden)]
|
||||
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
|
||||
check_password(password)?;
|
||||
let alg_id = match key_bits {
|
||||
128 => ALG_AES_128,
|
||||
192 => ALG_AES_192,
|
||||
256 => ALG_AES_256,
|
||||
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
|
||||
};
|
||||
let salt = random(SALT)?;
|
||||
let verifier = random(SALT)?;
|
||||
let key = standard_key(&salt, password, key_bits as usize / 8);
|
||||
let mut header = Vec::new();
|
||||
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
|
||||
header.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
// CSPName: an empty null-terminated UTF-16 string.
|
||||
header.extend_from_slice(&[0, 0]);
|
||||
let mut info = vec![3, 0, 2, 0];
|
||||
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&(SALT as u32).to_le_bytes());
|
||||
info.extend_from_slice(&salt);
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true)?);
|
||||
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
|
||||
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
|
||||
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
|
||||
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
|
||||
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
|
||||
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
|
||||
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
|
||||
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
|
||||
comp.flush().map_err(io)?;
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
|
||||
pub fn check_password(password: &str) -> Result<(), DocxError> {
|
||||
if password.is_empty() {
|
||||
@@ -496,6 +646,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
|
||||
k
|
||||
}
|
||||
|
||||
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
|
||||
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
|
||||
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
|
||||
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
|
||||
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
|
||||
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
|
||||
let x = |fill: u8| {
|
||||
let mut buf = Zeroizing::new([fill; 64]);
|
||||
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
|
||||
*b ^= h;
|
||||
}
|
||||
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
|
||||
};
|
||||
let mut key = Zeroizing::new(x(0x36).to_vec());
|
||||
key.extend_from_slice(&x(0x5C));
|
||||
key.truncate(len);
|
||||
key
|
||||
}
|
||||
|
||||
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
|
||||
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
|
||||
let aes = Aes::new(key)?;
|
||||
let (blocks, rest) = data.as_chunks::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("encrypted data isn't a whole number of blocks"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
let mut x = aes::Block::from(*b);
|
||||
if encrypt {
|
||||
aes.encrypt(&mut x);
|
||||
} else {
|
||||
aes.decrypt(&mut x);
|
||||
}
|
||||
out.extend_from_slice(&x);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
|
||||
/// the block size or padded with 0x36.
|
||||
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
|
||||
@@ -610,7 +799,7 @@ struct Info {
|
||||
block_size: usize,
|
||||
key_bytes: usize,
|
||||
/// encryptedHmacKey, encryptedHmacValue.
|
||||
integrity: Option<(Vec<u8>, Vec<u8>)>,
|
||||
integrity: (Vec<u8>, Vec<u8>),
|
||||
password: PasswordEncryptor,
|
||||
}
|
||||
|
||||
@@ -642,7 +831,6 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
};
|
||||
match (major, minor) {
|
||||
(4, 4) => {}
|
||||
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
|
||||
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
|
||||
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
|
||||
}
|
||||
@@ -652,10 +840,12 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
}
|
||||
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
|
||||
let kd = params(key_data)?;
|
||||
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
|
||||
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
|
||||
None => None,
|
||||
};
|
||||
// The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it.
|
||||
let di = root
|
||||
.els()
|
||||
.find(|e| e.local() == "dataIntegrity")
|
||||
.ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?;
|
||||
let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?);
|
||||
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
|
||||
// encryptor has none).
|
||||
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
|
||||
@@ -896,6 +1086,22 @@ mod tests {
|
||||
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_data_integrity_is_refused() {
|
||||
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
let mut stripped = info[..8].to_vec();
|
||||
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
|
||||
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_package_fails_the_integrity_check() {
|
||||
let pkg = zip_package();
|
||||
@@ -955,8 +1161,8 @@ mod tests {
|
||||
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
|
||||
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
|
||||
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
|
||||
// Data integrity is optional; without it a good file still opens.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
|
||||
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
|
||||
@@ -973,4 +1179,184 @@ mod tests {
|
||||
assert!(!is_encrypted(&fake));
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
#[test]
|
||||
fn standard_key_matches_an_independent_derivation() {
|
||||
let salt: Vec<u8> = (0..16).collect();
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
|
||||
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
|
||||
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
|
||||
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
|
||||
let k128: Vec<u8> = (0..16).collect();
|
||||
let k256: Vec<u8> = (0..32).collect();
|
||||
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
|
||||
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
|
||||
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
|
||||
assert!(ecb(&k128, &pt[..15], false).is_err());
|
||||
}
|
||||
|
||||
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
|
||||
/// AES key size, and says when the password is missing or wrong — as agile files do.
|
||||
#[test]
|
||||
fn standard_encryption_round_trips() {
|
||||
let pkg = zip_package();
|
||||
for bits in [128, 192, 256] {
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
|
||||
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
}
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
|
||||
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
// Sizes on and off the block boundary, and empty.
|
||||
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
|
||||
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
|
||||
}
|
||||
|
||||
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
|
||||
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
|
||||
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
|
||||
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
|
||||
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
|
||||
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
|
||||
let verifier: [u8; 16] = *b"verifier-16bytes";
|
||||
let key = standard_key(&salt, password, 16);
|
||||
let mut hash = sha1::Sha1::digest(verifier).to_vec();
|
||||
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
|
||||
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
|
||||
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
|
||||
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
|
||||
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
|
||||
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
|
||||
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
|
||||
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
|
||||
|
||||
let mut info = Vec::new();
|
||||
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
|
||||
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
|
||||
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
|
||||
info.extend_from_slice(&salt); // Salt
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
|
||||
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
|
||||
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
|
||||
|
||||
let mut padded = payload.to_vec();
|
||||
padded.resize(payload.len().next_multiple_of(16), 0);
|
||||
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
|
||||
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
|
||||
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
|
||||
cfb_with(&info, &package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_package_built_by_hand_from_the_spec_opens() {
|
||||
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
|
||||
let file = hand_built_standard(&payload, "pw");
|
||||
assert!(is_encrypted(&file));
|
||||
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
|
||||
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
|
||||
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
|
||||
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
|
||||
let (hand, _) = streams(&file);
|
||||
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
|
||||
assert_eq!(hand[..8], made[..8]);
|
||||
assert_eq!(hand[12..44], made[12..44]);
|
||||
}
|
||||
|
||||
/// The two streams of an encrypted package.
|
||||
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
(info, package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_standard_encryption_info_errors_without_panicking() {
|
||||
let data = b"a small package".to_vec();
|
||||
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
|
||||
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
|
||||
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
|
||||
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
|
||||
assert_eq!(info.len(), 118);
|
||||
let put = |at: usize, v: u32| {
|
||||
let mut i = info.clone();
|
||||
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
i
|
||||
};
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
for n in 0..info.len() {
|
||||
cases.push((info[..n].to_vec(), package.clone()));
|
||||
}
|
||||
for v in [0, 4, 31, 0x1000, u32::MAX] {
|
||||
cases.push((put(8, v), package.clone()));
|
||||
}
|
||||
// No AES, no CryptoAPI, an external (extensible) provider.
|
||||
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
|
||||
cases.push((put(4, v), package.clone()));
|
||||
}
|
||||
// RC4, MD5, key sizes that don't fit the algorithm.
|
||||
cases.push((put(20, 0x6801), package.clone()));
|
||||
cases.push((put(24, 0x8003), package.clone()));
|
||||
for v in [0, 64, 192, 256, u32::MAX] {
|
||||
cases.push((put(28, v), package.clone()));
|
||||
}
|
||||
for v in [0, 15, 17, u32::MAX] {
|
||||
cases.push((put(46, v), package.clone()));
|
||||
}
|
||||
for v in [0, 16, 32, u32::MAX] {
|
||||
cases.push((put(82, v), package.clone()));
|
||||
}
|
||||
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
|
||||
// isn't whole blocks.
|
||||
for n in [0, 4, 8, 9, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec()));
|
||||
}
|
||||
for size in [u64::MAX, 1 << 40, package.len() as u64] {
|
||||
let mut p = package.clone();
|
||||
p[..8].copy_from_slice(&size.to_le_bytes());
|
||||
cases.push((info.clone(), p));
|
||||
}
|
||||
for (i, p) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
assert!(decrypt(&file, Some("pw")).is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
|
||||
for v in [2u8, 4] {
|
||||
let mut i = info.clone();
|
||||
i[0] = v;
|
||||
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
let mut p = package.clone();
|
||||
p.extend_from_slice(&[1, 2, 3]);
|
||||
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
|
||||
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
|
||||
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
|
||||
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
}
|
||||
|
||||
+119
-5
@@ -9,7 +9,7 @@
|
||||
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
|
||||
//! capped as the XML tree reader caps a part's.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use base64::Engine as _;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
@@ -340,14 +340,21 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
|
||||
let rest = frag.get(open..).unwrap_or_default();
|
||||
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
|
||||
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
|
||||
let tag = rest.get(..tag_end).unwrap_or_default();
|
||||
let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default());
|
||||
let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1));
|
||||
let declared: HashSet<Vec<u8>> = start
|
||||
.attributes()
|
||||
.with_checks(false)
|
||||
.flatten()
|
||||
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
|
||||
.map(|a| a.key.as_ref().to_vec())
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(rest.len() + 256);
|
||||
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
|
||||
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for (k, v) in decls.iter().rev() {
|
||||
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
|
||||
if declared || seen.contains(&k.as_str()) {
|
||||
if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
seen.push(k);
|
||||
@@ -430,7 +437,47 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
|
||||
None => s,
|
||||
};
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE") && one_element(s)).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether `s` is exactly one well-formed element, with only whitespace, comments and processing
|
||||
/// instructions around it. Kept bytes are inlined into `pkg:xmlData` only then; anything else could
|
||||
/// close the wrapper and add parts of its own, so it goes out as `pkg:binaryData` (#3742).
|
||||
fn one_element(s: &str) -> bool {
|
||||
let mut r = quick_xml::Reader::from_str(s);
|
||||
r.config_mut().check_comments = true;
|
||||
let (mut depth, mut roots) = (0usize, 0usize);
|
||||
loop {
|
||||
let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok());
|
||||
match r.read_event() {
|
||||
Ok(Event::Start(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
depth += 1;
|
||||
}
|
||||
Ok(Event::Empty(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
}
|
||||
Ok(Event::End(_)) => match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => return false,
|
||||
},
|
||||
Ok(Event::Text(t)) if depth == 0 => {
|
||||
if !t.iter().all(u8::is_ascii_whitespace) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(_) | Event::GeneralRef(_)) if depth == 0 => return false,
|
||||
Ok(Event::Decl(_) | Event::DocType(_)) | Err(_) => return false,
|
||||
Ok(Event::Eof) => return depth == 0 && roots == 1,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -463,6 +510,15 @@ mod tests {
|
||||
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_namespaces_do_not_rescan_a_long_root_tag() {
|
||||
let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::<Vec<_>>()];
|
||||
let frag = format!("<w:document{}><w:body/></w:document>", " a=\"1\"".repeat(4_000_000));
|
||||
let out = with_scope(frag.as_bytes(), &scope);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
assert_eq!(out.matches("xmlns:p").count(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
|
||||
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
|
||||
@@ -478,6 +534,64 @@ mod tests {
|
||||
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_xml_that_would_break_out_of_its_part_is_written_in_base64() {
|
||||
// A part kept from a file is written back as it was read; inlined as is, markup that
|
||||
// closes the wrapper adds parts of its own (here a second core properties part, read
|
||||
// before the real one) (#3742).
|
||||
let evil = format!(
|
||||
"<a/></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/docProps/core.xml\" pkg:contentType=\"application/vnd.openxmlformats-package.core-properties+xml\"><pkg:xmlData><cp:coreProperties xmlns:cp=\"{}\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Injected</dc:title></cp:coreProperties></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/x.xml\"><pkg:xmlData><b>",
|
||||
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
);
|
||||
let w_ns = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
|
||||
let rel = |id: &str, kind: &str, target: &str| format!("<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{target}\"/>");
|
||||
let rels = format!(
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}{}</Relationships>",
|
||||
rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"),
|
||||
rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml")
|
||||
);
|
||||
let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/></Types>";
|
||||
let z = crate::package::zip_entries(&[
|
||||
("[Content_Types].xml".into(), types.as_bytes().to_vec()),
|
||||
("_rels/.rels".into(), rels.into_bytes()),
|
||||
("word/document.xml".into(), format!("<w:document {w_ns}><w:body><w:p/></w:body></w:document>").into_bytes()),
|
||||
("customXml/item1.xml".into(), evil.clone().into_bytes()),
|
||||
])
|
||||
.unwrap();
|
||||
let mut doc = crate::read(&z).unwrap();
|
||||
assert!(doc.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part is kept as read");
|
||||
doc.core.title = "Real".into();
|
||||
let flat = write(&doc).unwrap();
|
||||
let text = String::from_utf8_lossy(&flat);
|
||||
assert_eq!(text.matches("pkg:name=\"/docProps/core.xml\"").count(), 1, "{text}");
|
||||
let back = read(&flat).unwrap();
|
||||
assert_eq!(back.core.title, "Real");
|
||||
assert!(back.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part survives, in base64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_well_formed_element_is_inlined() {
|
||||
let ct = "application/xml";
|
||||
for ok in ["<a/>", "<?xml version=\"1.0\"?>\n<a x=\"1\"><b>t & <![CDATA[c]]></b></a>\n<!-- end -->", "<!-- c --><a/><?pi x?>"] {
|
||||
assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"<a/><b/>",
|
||||
"<a></b>",
|
||||
"<a>",
|
||||
"<a/></x>",
|
||||
"<a/>text",
|
||||
"<a/>&",
|
||||
"<a x=1/>",
|
||||
"<a/><?xml version=\"1.0\"?>",
|
||||
"<a></a></pkg:xmlData>",
|
||||
"<a><!-- x -- y --></a>",
|
||||
"<a/><!-- x--->",
|
||||
] {
|
||||
assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nameless_parts_count_against_the_size_limits() {
|
||||
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
|
||||
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
@@ -11,6 +13,11 @@ use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
/// Most font files taken from one document.
|
||||
const MAX_FONTS: usize = 64;
|
||||
|
||||
/// Most bytes of font files copied out of one document: one part's limit, however many
|
||||
/// references the font table makes (each reference is a copy; 64 of them to one large part
|
||||
/// were 64 copies, #3692).
|
||||
const MAX_FONT_BYTES: u64 = crate::package::MAX_PART;
|
||||
|
||||
/// The font table's references to embedded font files, one per style.
|
||||
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
|
||||
|
||||
@@ -28,6 +35,12 @@ pub struct EmbeddedFont {
|
||||
/// fonts: that is up to the font database. Empty for a document read from anything but a package
|
||||
/// with a font table.
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
fonts_within(doc, MAX_FONT_BYTES)
|
||||
}
|
||||
|
||||
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
|
||||
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
|
||||
let budget_total = budget;
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -36,6 +49,9 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// A reference repeating one already taken (same file, key and name) gives the same font.
|
||||
let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new();
|
||||
let mut over_budget = 0usize;
|
||||
let embeds = table
|
||||
.children("w:font")
|
||||
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
|
||||
@@ -61,12 +77,25 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
},
|
||||
None => [0; 16],
|
||||
};
|
||||
if !taken.insert((rel.target.as_str(), key, name)) {
|
||||
continue;
|
||||
}
|
||||
// Checked before the copy is made.
|
||||
let Some(left) = budget.checked_sub(bytes.len() as u64) else {
|
||||
over_budget += 1;
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let mut data = bytes.to_vec();
|
||||
for (i, b) in data.iter_mut().take(32).enumerate() {
|
||||
*b ^= key.get(i % 16).copied().unwrap_or(0);
|
||||
}
|
||||
out.push(EmbeddedFont { name: name.to_string(), data });
|
||||
}
|
||||
if over_budget > 0 {
|
||||
// Once for the document, not once per font.
|
||||
log::warn!("docx: embedded fonts past the {budget_total} byte budget; {over_budget} of them are not loaded");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -83,3 +112,53 @@ fn font_key(guid: &str) -> Option<[u8; 16]> {
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
|
||||
/// `parts` (`(name, size)`).
|
||||
fn doc_with(refs: &[(&str, &str)], parts: &[(&str, usize)]) -> Document {
|
||||
let mut table = String::from(
|
||||
r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
let mut manifest = format!("P\t{FONT_TABLE_PART}\tapplication/xml\n");
|
||||
for (i, (font, part)) in refs.iter().enumerate() {
|
||||
table.push_str(&format!(r#"<w:font w:name="{font}"><w:embedRegular r:id="rId{i}"/></w:font>"#));
|
||||
manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n"));
|
||||
}
|
||||
table.push_str("</w:fonts>");
|
||||
let mut doc = Document::default();
|
||||
doc.passthrough.insert(FONT_TABLE_PART.into(), Arc::new(table.into_bytes()));
|
||||
doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest.into_bytes()));
|
||||
for (part, size) in parts {
|
||||
doc.passthrough.insert((*part).into(), Arc::new(vec![7u8; *size]));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_referenced_again_is_copied_once() {
|
||||
// Each reference was its own copy: the 64 references allowed to one 256 MB part were
|
||||
// 16 GB of copies (#3692).
|
||||
let refs = vec![("Body", "word/fonts/a.odttf"); MAX_FONTS];
|
||||
let fonts = embedded_fonts(&doc_with(&refs, &[("word/fonts/a.odttf", 1000)]));
|
||||
assert_eq!(fonts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_copies_stop_at_the_byte_budget() {
|
||||
let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect();
|
||||
let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect();
|
||||
let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]);
|
||||
let (fonts, warned) = crate::test_log::warnings(|| fonts_within(&doc, 3500));
|
||||
assert_eq!(fonts.len(), 3, "three copies fit");
|
||||
let budget: Vec<_> = warned.iter().filter(|w| w.contains("byte budget")).collect();
|
||||
assert_eq!(budget.len(), 1, "one warning for the document, not one per font: {warned:?}");
|
||||
assert!(budget.iter().all(|w| w.contains(&format!("{} of them", MAX_FONTS - 3))), "{budget:?}");
|
||||
assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod fonts;
|
||||
mod package;
|
||||
mod read;
|
||||
mod smart_art;
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
@@ -25,8 +27,8 @@ mod xml;
|
||||
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
|
||||
pub use chart_spec::{chart_items, chart_xml};
|
||||
pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
|
||||
encrypt_with_spin_count, is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
|
||||
+134
-12
@@ -1,6 +1,7 @@
|
||||
//! OPC package access: bounded zip reading, relationships, part names.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -191,6 +192,9 @@ pub fn rel_is(t: &str, full: &str) -> bool {
|
||||
/// picture, an embedded object) is handed over rather than copied.
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Lowercased part name → its stored spelling (the first in name order): part names are
|
||||
/// case-insensitive (ECMA-376 Part 2 §6.2.2.3), so every spelling is one part.
|
||||
lower: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -223,24 +227,60 @@ impl Package {
|
||||
let room = max_part.min(max_total.saturating_sub(total));
|
||||
let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(room + 1).read_to_end(&mut buf);
|
||||
// Charged whether or not the read succeeds: an entry inflates in full before its CRC
|
||||
// is checked, so failed entries would otherwise inflate without limit (#3723).
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
if let Err(e) = read {
|
||||
// A corrupt entry: keep going unless it's a part we can't do without.
|
||||
log::warn!("docx: zip entry {name} unreadable: {e}");
|
||||
continue;
|
||||
}
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package { files })
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
||||
Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect())
|
||||
}
|
||||
|
||||
fn new(mut files: BTreeMap<String, Arc<Vec<u8>>>) -> Package {
|
||||
let mut lower: HashMap<String, String> = HashMap::new();
|
||||
let mut collides = false;
|
||||
for name in files.keys() {
|
||||
match lower.entry(name.to_ascii_lowercase()) {
|
||||
Entry::Occupied(_) => collides = true,
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collides {
|
||||
return Package { files, lower };
|
||||
}
|
||||
// Entries whose names differ only in case are one part, so every spelling of the name
|
||||
// reaches the same stored entry (#3704). The spelling kept is the one a relationship
|
||||
// names, so the part the document points at is the one read; the first in byte order only
|
||||
// when no relationship names any of them.
|
||||
let named = rel_targets(&files);
|
||||
for name in files.keys().rev().filter(|n| named.contains(n.as_str())) {
|
||||
lower.insert(name.to_ascii_lowercase(), name.clone());
|
||||
}
|
||||
let before = files.len();
|
||||
files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name));
|
||||
let dropped = before.saturating_sub(files.len());
|
||||
log::warn!("docx: {dropped} zip entries are other spellings of parts already read; they are left out");
|
||||
Package { files, lower }
|
||||
}
|
||||
|
||||
/// The stored spelling of part `name` (leading `/` ignored; case-insensitive).
|
||||
pub fn canonical(&self, name: &str) -> Option<&str> {
|
||||
self.lower.get(&name.trim_start_matches('/').to_ascii_lowercase()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
@@ -251,11 +291,15 @@ impl Package {
|
||||
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
||||
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
let n = name.trim_start_matches('/');
|
||||
if let Some(v) = self.files.get(n) {
|
||||
return Some(v);
|
||||
self.canonical(name).and_then(|n| self.files.get(n))
|
||||
}
|
||||
|
||||
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
|
||||
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
match self.get(name) {
|
||||
Some(b) => xml::parse_strict(b).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
@@ -281,7 +325,14 @@ impl Package {
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
let resolved = if external { target.to_string() } else { resolve(dir, target) };
|
||||
// Internal targets name the part as stored, so a part reached under several spellings
|
||||
// is kept, cached and written once (#3704).
|
||||
let resolved = if external {
|
||||
target.to_string()
|
||||
} else {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -356,6 +407,24 @@ fn split_dir(part: &str) -> (&str, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every internal part name the relationship parts in `files` target, spelled as they spell it.
|
||||
fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
|
||||
let mut named = HashSet::new();
|
||||
for (name, bytes) in files {
|
||||
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
|
||||
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
|
||||
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
|
||||
let Ok(root) = xml::parse(bytes) else { continue };
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if let (Some(target), false) = (r.attr("Target"), external) {
|
||||
named.insert(resolve(dir, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Resolve a relative target against a source directory into a package path.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.split('#').next().unwrap_or(target);
|
||||
@@ -412,6 +481,39 @@ mod tests {
|
||||
zip_entries(&e).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_entries_are_one_part() {
|
||||
let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]);
|
||||
let p = Package::open(&z).unwrap();
|
||||
for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG"] {
|
||||
assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}");
|
||||
assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}");
|
||||
}
|
||||
assert_eq!(p.files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spelling_a_relationship_names_is_kept() {
|
||||
// The first spelling in byte order ("Image.png") was kept even when the document's
|
||||
// relationship named the other one, so the picture shown was not the one it pointed at.
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="image" Target="media/image.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("word/media/Image.png", b"upper".to_vec()),
|
||||
("word/media/image.png", b"lower".to_vec()),
|
||||
("word/media/Other.png", b"first".to_vec()),
|
||||
("word/media/other.png", b"second".to_vec()),
|
||||
]);
|
||||
let (p, warned) = crate::test_log::warnings(|| Package::open(&z).unwrap());
|
||||
assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png"));
|
||||
assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..]));
|
||||
assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order");
|
||||
assert_eq!(p.files.len(), 3);
|
||||
let folded: Vec<_> = warned.iter().filter(|w| w.contains("other spellings")).collect();
|
||||
assert_eq!(folded.len(), 1, "one warning for the package: {warned:?}");
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
@@ -421,6 +523,26 @@ mod tests {
|
||||
assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_that_fail_their_check_still_count_against_the_total() {
|
||||
// Each entry inflates in full before its CRC is checked; a failed one was dropped without
|
||||
// being charged, so any number of them inflated (#3723).
|
||||
let names: Vec<String> = (0..20).map(|i| format!("p{i}.bin")).collect();
|
||||
let entries: Vec<(&str, Vec<u8>)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect();
|
||||
let mut z = zip_of(&entries);
|
||||
let mut patched = 0;
|
||||
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
|
||||
let crc_at = if sig[2] == 1 { 16 } else { 14 };
|
||||
let found: Vec<usize> = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect();
|
||||
for pos in found {
|
||||
z[pos + crc_at] ^= 0xFF;
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 40);
|
||||
assert!(matches!(Package::open_limited(&z, 2_000_000, 4_000_000), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_declared_size_is_rejected_or_bounded() {
|
||||
let mut z = zip_of(&[("a.xml", b"<a/>".to_vec())]);
|
||||
|
||||
@@ -471,6 +471,9 @@ impl Series {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored.
|
||||
const MAX_FORMAT_CODE: usize = 255;
|
||||
|
||||
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
|
||||
/// format code (dates included); text and `General` numbers as they are stored.
|
||||
fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
||||
@@ -478,7 +481,8 @@ fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
||||
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General"));
|
||||
let code =
|
||||
cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General"));
|
||||
pts(cache, cap)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -505,7 +509,7 @@ fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
|
||||
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty());
|
||||
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
|
||||
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
(vals, fmt)
|
||||
}
|
||||
@@ -541,7 +545,12 @@ fn axis(a: Option<&El>) -> Axis {
|
||||
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
|
||||
min: num("c:min"),
|
||||
max: num("c:max"),
|
||||
fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string),
|
||||
fmt: a
|
||||
.child("c:numFmt")
|
||||
.filter(|f| f.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|f| f.attr("formatCode"))
|
||||
.filter(|f| f.len() <= MAX_FORMAT_CODE)
|
||||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2174,6 +2183,44 @@ mod tests {
|
||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_format_code_length_is_capped() {
|
||||
let cat = |code: &str| -> Vec<String> {
|
||||
let x = format!(
|
||||
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
|
||||
);
|
||||
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert!(cat(&ok)[0].starts_with("xxx"));
|
||||
let long = format!("{}0", "x".repeat(300));
|
||||
assert_eq!(cat(&long), ["5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_format_code_length_is_capped() {
|
||||
let val = |code: &str| -> Option<String> {
|
||||
let x = format!(
|
||||
r#"<c:val {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>"#
|
||||
);
|
||||
numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(val(&ok), Some(ok.clone()));
|
||||
assert_eq!(val(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_format_code_length_is_capped() {
|
||||
let fmt = |code: &str| -> Option<String> {
|
||||
let x = format!(r#"<c:valAx {NS}><c:numFmt formatCode="{code}" sourceLinked="0"/></c:valAx>"#);
|
||||
axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(fmt(&ok), Some(ok.clone()));
|
||||
assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_is_exact_beyond_chronos_range() {
|
||||
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||
|
||||
+177
-12
@@ -9,6 +9,7 @@
|
||||
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
|
||||
//! are kept the same way.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
|
||||
@@ -17,7 +18,7 @@ use super::Reader;
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
|
||||
};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc, is_rel_attr};
|
||||
|
||||
/// Relationship types (last path segment) the reader models or the writer produces itself, so
|
||||
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
|
||||
@@ -64,6 +65,51 @@ pub const HANDLED_RELS: &[&str] = &[
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
/// The relationship ids the main document's markup refers to: the ids it names in a
|
||||
/// relationship attribute (`r:id="rId5"`, VML's `o:relid`) — the same rule the embedded objects
|
||||
/// use ([`rel_ids`]).
|
||||
///
|
||||
/// One pass over the markup the reader has already parsed, not one search per id: the ids are
|
||||
/// looked up as they are met, so the cost is the size of the document rather than its size
|
||||
/// times the number of relationships (#12). A name that is not a relationship is ignored, and a
|
||||
/// name that is not one of the document's relationship ids is not collected. An id that only
|
||||
/// appears as text is not a reference: the text does not name a part, and treating it as one
|
||||
/// lost the part on save.
|
||||
fn referenced_ids<'a>(root: &El, document: &'a Rels) -> HashSet<&'a [u8]> {
|
||||
// The ids the loop in `keep_unmodelled` can ask about: the first `MAX_EMBEDDED_RELS`
|
||||
// non-external relationships, and no more, so the set stays bounded however many the file
|
||||
// carries.
|
||||
let ids: HashSet<&[u8]> = document.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS).map(|r| r.id.as_bytes()).collect();
|
||||
let mut found = HashSet::new();
|
||||
if ids.is_empty() {
|
||||
return found;
|
||||
}
|
||||
collect_rel_ids(root, &ids, &mut found, 0);
|
||||
found
|
||||
}
|
||||
|
||||
/// The ids `e` names in a relationship attribute that are in `ids`, collected into `found`.
|
||||
///
|
||||
/// The attribute is a relationship attribute by [`is_rel_attr`]: one in the relationship
|
||||
/// namespace, VML's `o:relid`, or the unqualified alias of one on an element that carries a
|
||||
/// relationship — the same attribute the reader resolves a relationship from, so a part the
|
||||
/// markup names is not carried over a second time.
|
||||
fn collect_rel_ids<'a>(e: &El, ids: &HashSet<&'a [u8]>, found: &mut HashSet<&'a [u8]>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if is_rel_attr(&e.name, k)
|
||||
&& let Some(id) = ids.get(v.as_bytes())
|
||||
{
|
||||
found.insert(*id);
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
collect_rel_ids(c, ids, found, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep custom XML stores reached from the main document or package, without evaluating
|
||||
/// bindings. A bibliography already loaded into the source model belongs to its writer.
|
||||
@@ -99,13 +145,12 @@ impl Reader<'_> {
|
||||
/// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any
|
||||
/// relationship the main document's markup refers to by id (its target goes with the
|
||||
/// element, or not at all).
|
||||
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
|
||||
let pkg = self.pkg;
|
||||
let main = pkg.get(&self.main).unwrap_or_default();
|
||||
let referenced = |id: &str| {
|
||||
let needle = format!("\"{id}\"");
|
||||
main.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
};
|
||||
pub(super) fn keep_unmodelled(&mut self, root: &El, package: &Rels, document: &Rels) {
|
||||
// The ids the main document's markup refers to, in one pass over the markup: an id is
|
||||
// named in a relationship attribute (`r:id="rId5"`). One scan for all of them, not one
|
||||
// search per relationship — a large document with many relationships took minutes to
|
||||
// open (#12).
|
||||
let referenced = referenced_ids(root, document);
|
||||
let mut roots = String::new();
|
||||
// One glossary per main document (its first relationship; see `read_glossary`).
|
||||
let mut glossary = false;
|
||||
@@ -113,7 +158,7 @@ impl Reader<'_> {
|
||||
for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) {
|
||||
let tail = rel.kind.rsplit('/').next().unwrap_or("");
|
||||
if HANDLED_RELS.contains(&tail)
|
||||
|| (owner == "D" && referenced(&rel.id))
|
||||
|| (owner == "D" && referenced.contains(rel.id.as_bytes()))
|
||||
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|
||||
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
|
||||
{
|
||||
@@ -215,14 +260,15 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's
|
||||
/// `o:relid`), in document order. `None` past [`MAX_IDS`].
|
||||
/// The relationship ids `e` uses (attributes in the relationships namespace, VML's `o:relid`,
|
||||
/// and the unqualified alias of one on an element that carries a relationship), in document
|
||||
/// order. `None` past [`MAX_IDS`].
|
||||
fn rel_ids(e: &El, out: &mut Vec<String>, depth: usize) -> Option<()> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Some(());
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if (k.starts_with("r:") || k == "o:relid") && !out.contains(v) {
|
||||
if is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
if out.len() >= MAX_IDS {
|
||||
return None;
|
||||
}
|
||||
@@ -247,3 +293,122 @@ fn standalone_xml(e: &El) -> Option<String> {
|
||||
s.push_str(rest);
|
||||
(s.len() <= MAX_EMBEDDED_XML).then_some(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use crate::package::UNMODELLED_ROOTS;
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
|
||||
/// A package whose main part holds `body` (the XML inside `w:body`), and which relates to
|
||||
/// `rels` parts of a type the reader doesn't model, so each one is looked for in the part.
|
||||
fn package(body: &str, rels: &[(&str, &str)], parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut r = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for (id, target) in rels {
|
||||
r.push_str(&format!(
|
||||
r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings" Target="{target}"/>"#
|
||||
));
|
||||
}
|
||||
r.push_str("</Relationships>");
|
||||
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
zw.start_file("[Content_Types].xml", opts).unwrap();
|
||||
zw.write_all(CONTENT_TYPES.as_bytes()).unwrap();
|
||||
zw.start_file("_rels/.rels", opts).unwrap();
|
||||
zw.write_all(ROOT_RELS.as_bytes()).unwrap();
|
||||
zw.start_file("word/document.xml", opts).unwrap();
|
||||
zw.write_all(doc.as_bytes()).unwrap();
|
||||
zw.start_file("word/_rels/document.xml.rels", opts).unwrap();
|
||||
zw.write_all(r.as_bytes()).unwrap();
|
||||
for (name, bytes) in parts {
|
||||
zw.start_file(name, opts).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The parts the reader carries over unchanged, as one string.
|
||||
fn unmodelled_roots(d: &wordcraft_doc::Document) -> String {
|
||||
d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Opening a document costs the size of its main part, not that size times the number of its
|
||||
/// relationships (#12). Eight megabytes of main part with a thousand relationships used to
|
||||
/// take half a minute; the scan is now one pass over the markup.
|
||||
#[test]
|
||||
fn a_document_with_many_relationships_opens_in_the_time_its_part_takes() {
|
||||
let rels: Vec<(String, String)> = (0..1024).map(|i| (format!("rIdT{i}"), format!("file{i}.bin"))).collect();
|
||||
let rels: Vec<(&str, &str)> = rels.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
|
||||
let body = format!(r#"<w:p><w:r><w:t xml:space="preserve">{}</w:t></w:r></w:p>"#, "a".repeat(8 << 20));
|
||||
let bytes = package(&body, &rels, &[]);
|
||||
let t = std::time::Instant::now();
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let took = t.elapsed();
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).len(), 8 << 20, "the text is there");
|
||||
assert!(took.as_secs() < 15, "opening it took {took:?}");
|
||||
}
|
||||
|
||||
/// The scan finds the relationships the markup names, so a part the document already refers
|
||||
/// to is not carried over a second time (#12).
|
||||
#[test]
|
||||
fn a_relationship_the_markup_names_is_not_carried_over_again() {
|
||||
// The markup names the relationship, so it is not carried over a second time as an
|
||||
// unmodelled part. (The part itself survives only if the writer writes it with the
|
||||
// element that names it; the reader does not carry it on its own.)
|
||||
let named = package(
|
||||
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p><w:sectPr><w:printerSettings r:id="rIdDoc1"/></w:sectPr>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&named).unwrap());
|
||||
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
|
||||
|
||||
// A part the markup doesn't name is carried over, as before.
|
||||
let unnamed = package(r#"<w:p><w:r><w:t>no mention here</w:t></w:r></w:p>"#, &[("rIdDoc1", "keep.bin")], &[("word/keep.bin", b"kept")]);
|
||||
let roots = unmodelled_roots(&crate::read(&unnamed).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// An id the markup only mentions as text does not name a part: the part is carried over,
|
||||
/// not dropped because the text looked like a reference.
|
||||
#[test]
|
||||
fn a_part_the_markup_only_mentions_is_carried_over() {
|
||||
let mentioned = package(
|
||||
r#"<w:p><w:r><w:t xml:space="preserve">before "rIdDoc1" after</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&mentioned).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
fn an_unqualified_attribute_names_a_relationship_only_where_it_is_one() {
|
||||
// A hyperlink whose relationship id is written without the prefix: the reader resolves
|
||||
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
|
||||
let named = package(
|
||||
r#"<w:p><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&named).unwrap());
|
||||
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
|
||||
|
||||
// A VML shape's `id` is its own: it names no part, so the part is carried over.
|
||||
let own = package(
|
||||
r#"<w:p><w:r><w:pict><v:shape id="rIdDoc1" style="width:10pt;height:10pt"/></w:pict></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
let roots = unmodelled_roots(&crate::read(&own).unwrap());
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
|
||||
};
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
// Cap the commands too, so a path of bare letters can't build a million entries.
|
||||
if out.len() >= MAX_VML_PARAMS {
|
||||
break;
|
||||
}
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
@@ -361,6 +365,13 @@ mod tests {
|
||||
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() {
|
||||
let path = "x".repeat(MAX_VML_PATH);
|
||||
let n = vml_commands(&path).len();
|
||||
assert!(n <= MAX_VML_PARAMS, "{n} commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bezier_segment_with_many_points_is_bounded() {
|
||||
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml(&main)? else {
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
return Err(DocxError::MissingPart(main));
|
||||
};
|
||||
if root.name != "w:document" {
|
||||
@@ -299,7 +299,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
}
|
||||
r.read_glossary(&rels);
|
||||
r.keep_custom_xml(&root_rels, &rels);
|
||||
r.keep_unmodelled(&root_rels, &rels);
|
||||
r.keep_unmodelled(&root, &root_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
@@ -513,7 +513,9 @@ fn toc_control_to_toc(body: &mut Blocks) {
|
||||
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()) {
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
@@ -901,6 +903,24 @@ impl Reader<'_> {
|
||||
{
|
||||
self.pc.alias.insert(id.to_string(), "Normal".into());
|
||||
}
|
||||
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
|
||||
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
|
||||
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
|
||||
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
|
||||
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
|
||||
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
|
||||
let canonical = match name.trim().to_ascii_lowercase().as_str() {
|
||||
"toc heading" => "TOCHeading".to_string(),
|
||||
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
|
||||
Some(d) => format!("TOC{d}"),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
|
||||
taken.push(canonical.clone());
|
||||
self.pc.alias.insert(id.to_string(), canonical);
|
||||
}
|
||||
}
|
||||
let mut sheet = StyleSheet::empty();
|
||||
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
|
||||
if let Some(dd) = root.child("w:docDefaults") {
|
||||
@@ -1192,6 +1212,7 @@ impl Reader<'_> {
|
||||
s.compat_mode = m.clamp(11, 99);
|
||||
}
|
||||
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
|
||||
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
|
||||
@@ -1342,6 +1363,10 @@ impl Reader<'_> {
|
||||
let mut by_para: HashMap<String, u32> = HashMap::new();
|
||||
let mut next_id = 0u32;
|
||||
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
|
||||
// Ids given before this part, looked up rather than scanned for every generated id: a
|
||||
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
|
||||
// `entries`.
|
||||
let given: HashSet<u32> = self.comment_map.values().copied().collect();
|
||||
for c in root.children("w:comment") {
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
@@ -1353,7 +1378,7 @@ impl Reader<'_> {
|
||||
let id = match file_id.parse::<u32>() {
|
||||
Ok(v) if !entries.contains_key(&v) => v,
|
||||
_ => {
|
||||
while entries.contains_key(&next_id) || self.comment_map.values().any(|v| *v == next_id) {
|
||||
while entries.contains_key(&next_id) || given.contains(&next_id) {
|
||||
next_id += 1;
|
||||
}
|
||||
next_id
|
||||
|
||||
@@ -231,22 +231,11 @@ impl PropCtx {
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
c.font =
|
||||
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|
||||
|| {
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
|
||||
// Latin font only when the run is hinted as that script. Otherwise the Latin
|
||||
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
|
||||
// `docDefaults` font, as in Word).
|
||||
match k.attr("w:hint") {
|
||||
Some("eastAsia") => k.attr("w:eastAsia"),
|
||||
Some("cs") => k.attr("w:cs"),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(str::to_string)
|
||||
},
|
||||
);
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
// Latin letters. A Normal style with only `w:eastAsia`/`w:cs` keeps the
|
||||
// `docDefaults` font, and so does a hinted run, as in Word.
|
||||
c.font = themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi"));
|
||||
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
|
||||
// left to inherit: the theme's per-script fonts aren't read.
|
||||
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
|
||||
|
||||
@@ -41,9 +41,10 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
|
||||
"straightConnector1" => ShapeKind::StraightConnector,
|
||||
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
|
||||
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
|
||||
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
|
||||
ShapeKind::Arrow
|
||||
}
|
||||
"rightArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => ShapeKind::Arrow,
|
||||
"leftArrow" => ShapeKind::ArrowLeft,
|
||||
"upArrow" => ShapeKind::ArrowUp,
|
||||
"downArrow" => ShapeKind::ArrowDown,
|
||||
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
|
||||
"heart" => ShapeKind::Heart,
|
||||
_ => ShapeKind::Rectangle,
|
||||
@@ -469,6 +470,15 @@ mod tests {
|
||||
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
|
||||
}
|
||||
|
||||
/// #3919: Word's up, down and left block arrows were all read as the right-pointing arrow.
|
||||
#[test]
|
||||
fn block_arrows_keep_their_direction() {
|
||||
assert_eq!(preset_kind("rightArrow"), ShapeKind::Arrow);
|
||||
assert_eq!(preset_kind("leftArrow"), ShapeKind::ArrowLeft);
|
||||
assert_eq!(preset_kind("upArrow"), ShapeKind::ArrowUp);
|
||||
assert_eq!(preset_kind("downArrow"), ShapeKind::ArrowDown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_presets_map_like_drawingml_presets() {
|
||||
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
|
||||
@@ -477,7 +487,7 @@ mod tests {
|
||||
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
|
||||
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
|
||||
// star, rounded callout, elbow connector, a bevel (its box).
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::ArrowLeft));
|
||||
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
|
||||
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
|
||||
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
|
||||
|
||||
@@ -724,7 +724,8 @@ impl Reader<'_> {
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
return;
|
||||
}
|
||||
let c = match k.attr("w:type") {
|
||||
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
|
||||
let c = match ty.as_deref() {
|
||||
Some("page") => "\u{000C}",
|
||||
Some("column") => "\u{000E}",
|
||||
_ => "\n",
|
||||
@@ -739,7 +740,13 @@ impl Reader<'_> {
|
||||
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
|
||||
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
|
||||
"w:sym" => {
|
||||
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
let h_str = k.attr("w:char").map(|h| {
|
||||
let s = h.trim();
|
||||
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
|
||||
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
|
||||
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
|
||||
});
|
||||
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
if let Some(ch) = ch {
|
||||
let mut p = props.clone();
|
||||
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
|
||||
@@ -783,7 +790,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:footnoteReference" | "w:endnoteReference" => {
|
||||
let foot = k.name == "w:footnoteReference";
|
||||
let foot = k.local() == "footnoteReference";
|
||||
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
|
||||
let map = if foot { &self.footnotes } else { &self.endnotes };
|
||||
if let Some(id) = map.get(&fid).copied() {
|
||||
@@ -794,7 +801,7 @@ impl Reader<'_> {
|
||||
}
|
||||
// The note's own number at the start of its text: a reference to the note being read.
|
||||
"w:footnoteRef" | "w:endnoteRef" => {
|
||||
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
if let Some((nk, id)) = self.current_note
|
||||
&& nk == kind
|
||||
{
|
||||
@@ -1066,6 +1073,10 @@ impl Reader<'_> {
|
||||
|
||||
// ---- drawings ----
|
||||
|
||||
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
|
||||
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
|
||||
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
|
||||
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
|
||||
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let (c, anchored) = match d.child("wp:inline") {
|
||||
Some(c) => (c, false),
|
||||
@@ -1142,13 +1153,16 @@ impl Reader<'_> {
|
||||
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
|
||||
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = super::freeform::ink_tool(name)
|
||||
{
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
// Ink's round ends belong to its tool, not the optional shape outline. Keep
|
||||
// its canonical model unchanged when reading the round cap the writer adds.
|
||||
extra.cap = Default::default();
|
||||
}
|
||||
obj
|
||||
};
|
||||
@@ -1461,6 +1475,10 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
|
||||
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
|
||||
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
|
||||
/// unknown caps use flat ends, without resolving theme-inherited caps.
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
float.set_spin(xfrm_spin(sppr));
|
||||
@@ -1533,7 +1551,12 @@ impl Reader<'_> {
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
|
||||
let mut extra = ShapeExtra {
|
||||
dash: super::shape_style::line_dash(ln),
|
||||
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
|
||||
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" {
|
||||
@@ -1728,6 +1751,15 @@ impl Reader<'_> {
|
||||
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 = vml_fill(shape, shapetype);
|
||||
let endcap = shape
|
||||
.child("v:stroke")
|
||||
.and_then(|s| s.attr("endcap"))
|
||||
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
|
||||
let cap = match endcap {
|
||||
Some("square") => wordcraft_doc::connector::LineCap::Square,
|
||||
Some("round") => wordcraft_doc::connector::LineCap::Round,
|
||||
_ => wordcraft_doc::connector::LineCap::Flat,
|
||||
};
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
@@ -1751,7 +1783,7 @@ impl Reader<'_> {
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty());
|
||||
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
|
||||
let warp = vml_spt(shape, shapetype, type_id)
|
||||
@@ -1830,7 +1862,7 @@ impl Reader<'_> {
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
|
||||
@@ -1859,11 +1891,11 @@ impl Reader<'_> {
|
||||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra::default();
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
|
||||
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
|
||||
@@ -2231,6 +2263,49 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
|
||||
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
|
||||
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
|
||||
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
|
||||
/// lone trailing `\` none of CSS would accept stays literal.
|
||||
fn css_string(v: &str) -> String {
|
||||
let v = v.trim();
|
||||
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
|
||||
let mut out = String::with_capacity(v.len());
|
||||
let mut chars = v.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 {
|
||||
match chars.clone().next() {
|
||||
Some(h) if h.is_ascii_hexdigit() => {
|
||||
hex.push(h);
|
||||
chars.next();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if hex.is_empty() {
|
||||
match chars.next() {
|
||||
Some(e) => out.push(e),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
} else {
|
||||
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
out.push(d);
|
||||
}
|
||||
// One whitespace after the escape is its terminator, not part of the value.
|
||||
if chars.clone().next().is_some_and(char::is_whitespace) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
|
||||
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
|
||||
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
|
||||
@@ -2332,7 +2407,7 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(default.font);
|
||||
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
|
||||
//! a limit which leaves something out says so.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Capture;
|
||||
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static CAPTURE: Capture = Capture;
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
// Only this logger is ever installed in a test binary; a second call keeps it.
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
|
||||
_ => rest,
|
||||
};
|
||||
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
|
||||
if after_sign.first().is_some_and(u8::is_ascii_digit) {
|
||||
in_exp = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
|
||||
//! parser's depth limit, pictures and stories are capped in number.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::Document;
|
||||
@@ -38,6 +38,8 @@ const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
|
||||
const MAX_MEDIA: usize = 4096;
|
||||
/// Most header, footer, footnote, endnote and comment stories written.
|
||||
const MAX_STORIES: usize = 50_000;
|
||||
/// Most bytes of header and footer parts inlined, all sections together.
|
||||
const MAX_INLINED_BYTES: usize = 64 << 20;
|
||||
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
|
||||
const MAX_PICT_PT: f64 = 31_680.0;
|
||||
|
||||
@@ -63,6 +65,9 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
if c.over_budget {
|
||||
return Err(DocxError::Limit(format!("headers and footers inlined past {MAX_INLINED_BYTES} bytes")));
|
||||
}
|
||||
|
||||
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
|
||||
if let Some(f) = c.default_fonts() {
|
||||
@@ -153,6 +158,14 @@ struct Conv<'a> {
|
||||
next_shape: i64,
|
||||
stories: usize,
|
||||
in_note: bool,
|
||||
/// Notes and comments already inlined: (kind, id). A repeated reference does not copy the body again.
|
||||
inlined_notes: HashSet<(char, String)>,
|
||||
/// The header or footer in effect per (footer, type), as the previous sections left it.
|
||||
hf_in_effect: HashMap<(bool, &'static str), String>,
|
||||
/// Bytes of header and footer parts inlined so far.
|
||||
inlined_bytes: usize,
|
||||
/// A header or footer did not fit in `MAX_INLINED_BYTES`; the export fails.
|
||||
over_budget: bool,
|
||||
}
|
||||
|
||||
impl<'a> Conv<'a> {
|
||||
@@ -197,6 +210,10 @@ impl<'a> Conv<'a> {
|
||||
next_shape: 1025,
|
||||
stories: 0,
|
||||
in_note: false,
|
||||
inlined_notes: HashSet::new(),
|
||||
hf_in_effect: HashMap::new(),
|
||||
inlined_bytes: 0,
|
||||
over_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,10 +385,14 @@ impl<'a> Conv<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
// The body goes at the first reference only; a repeated one keeps its mark, empty.
|
||||
if !self.inlined_notes.insert((if foot { 'f' } else { 'e' }, id.to_string())) {
|
||||
return vec![Node::El(plain(name, Vec::new()))];
|
||||
}
|
||||
self.stories += 1;
|
||||
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
|
||||
self.in_note = true;
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
let kids = self.kids(¬e, &rels, name, depth);
|
||||
self.in_note = false;
|
||||
vec![Node::El(plain(name, kids))]
|
||||
@@ -390,6 +411,10 @@ impl<'a> Conv<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
|
||||
// A comment is written once, at its first reference.
|
||||
if !self.inlined_notes.insert(('c', id.to_string())) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.stories += 1;
|
||||
let rels = self.comment_rels.clone();
|
||||
let n = self.ann_id('c', id);
|
||||
@@ -448,14 +473,27 @@ impl<'a> Conv<'a> {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let ty = match k.attr("w:type") {
|
||||
Some("even") => "even",
|
||||
Some("first") => "first",
|
||||
_ => "odd",
|
||||
};
|
||||
// A section without the story inherits the previous section's, as in OOXML: write
|
||||
// it only where it changes.
|
||||
if self.hf_in_effect.get(&(footer, ty)) == Some(&path) {
|
||||
continue;
|
||||
}
|
||||
let size = self.pkg.get(&path).map_or(0, |b| b.len());
|
||||
if self.inlined_bytes.saturating_add(size) > MAX_INLINED_BYTES {
|
||||
// Skipping would leave later sections inheriting the wrong story: fail instead.
|
||||
self.over_budget = true;
|
||||
continue;
|
||||
}
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
self.inlined_bytes = self.inlined_bytes.saturating_add(size);
|
||||
self.hf_in_effect.insert((footer, ty), path.clone());
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let name = if footer { "w:ftr" } else { "w:hdr" };
|
||||
let was = std::mem::replace(&mut self.in_note, true);
|
||||
let kids = self.kids(&root, &story_rels, name, depth + 1);
|
||||
|
||||
+201
-10
@@ -48,6 +48,10 @@ pub(crate) struct EmbedWriter<'d> {
|
||||
pub parts: Vec<(String, Vec<u8>, String, PartRels)>,
|
||||
/// Leave out the main document's kept glossary (it's written from the model instead).
|
||||
pub drop_glossary: bool,
|
||||
/// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]).
|
||||
scrubbed: HashMap<String, Vec<u8>>,
|
||||
/// A chart past the chart limits has been written (and the loss logged, once per save).
|
||||
chart_cut: bool,
|
||||
}
|
||||
|
||||
impl<'d> EmbedWriter<'d> {
|
||||
@@ -68,7 +72,7 @@ impl<'d> EmbedWriter<'d> {
|
||||
"[content_types].xml",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
|
||||
// The bibliography writer uses these names when sources are present (#532).
|
||||
@@ -93,6 +97,8 @@ impl<'d> EmbedWriter<'d> {
|
||||
markup_bytes: MAX_MARKUP_BYTES,
|
||||
parts: Vec::new(),
|
||||
drop_glossary: false,
|
||||
scrubbed: HashMap::new(),
|
||||
chart_cut: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +127,8 @@ impl<'d> EmbedWriter<'d> {
|
||||
|
||||
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
|
||||
fn alloc(&mut self, orig: &str) -> String {
|
||||
let clean = valid_part_name(orig);
|
||||
let orig = clean.as_str();
|
||||
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
|
||||
// part needs a different base name, otherwise every candidate would be reserved.
|
||||
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
|
||||
@@ -228,6 +236,44 @@ impl<'d> EmbedWriter<'d> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Have the main document's kept glossary, and the parts it reaches, written without the
|
||||
/// personal information (`names`) and dates the privacy settings remove (#3692). False when a
|
||||
/// part's markup can't be rewritten: the glossary is then written from the model.
|
||||
pub fn scrub_glossary(&mut self, names: bool, dates: bool) -> bool {
|
||||
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return true };
|
||||
let mut todo: Vec<String> = String::from_utf8_lossy(roots)
|
||||
.lines()
|
||||
.take(MAX_EMBEDDED_RELS * 2)
|
||||
.filter_map(|line| match line.split('\t').collect::<Vec<_>>().as_slice() {
|
||||
[source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
while let Some(p) = todo.pop() {
|
||||
if seen.len() >= MAX_EMBEDDED_PARTS || !seen.insert(p.clone()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(info), Some(bytes)) = (self.manifest.parts.get(&p), self.passthrough.get(&p)) else { continue };
|
||||
todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.clone()));
|
||||
if !(info.content_type.ends_with("xml") || p.to_ascii_lowercase().ends_with(".xml")) {
|
||||
continue;
|
||||
}
|
||||
match scrub_private(bytes, names, dates) {
|
||||
Some(b) => {
|
||||
self.scrubbed.insert(p, b);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"docx: the glossary's part {p} can't be cleaned of personal information; the glossary is written from its entries, without what WordCraft doesn't model"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What copying the parts reached from `roots` costs against the copy budget.
|
||||
fn tree_cost(&self, roots: &[&str]) -> u64 {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
@@ -281,8 +327,9 @@ impl<'d> EmbedWriter<'d> {
|
||||
rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false);
|
||||
}
|
||||
let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type };
|
||||
let bytes = self.scrubbed.get(&orig).cloned().unwrap_or_else(|| bytes.to_vec());
|
||||
written.push(self.parts.len());
|
||||
self.parts.push((new, bytes.to_vec(), ct, rels));
|
||||
self.parts.push((new, bytes, ct, rels));
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
@@ -406,7 +453,12 @@ impl super::Writer<'_> {
|
||||
/// related from the story part through `rels`.
|
||||
pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String {
|
||||
let name = self.embeds.fresh("word/charts/chart", ".xml");
|
||||
let bytes = crate::chart_spec::chart_xml(spec).into_bytes();
|
||||
let (xml, cut) = crate::chart_spec::chart_part(spec);
|
||||
if cut && !self.embeds.chart_cut {
|
||||
self.embeds.chart_cut = true;
|
||||
log::warn!("docx: a chart has more series or points than a chart part holds; the rest are left out of the file");
|
||||
}
|
||||
let bytes = xml.into_bytes();
|
||||
self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default()));
|
||||
let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false);
|
||||
let mut w = xml::W::default();
|
||||
@@ -585,17 +637,12 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)?
|
||||
fn is_rel_attr(k: &str) -> bool {
|
||||
k.starts_with("r:") || k == "o:relid"
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if is_rel_attr(k) && !out.contains(v) {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
}
|
||||
}
|
||||
@@ -608,8 +655,9 @@ fn set_rel_ids(e: &mut El, ids: &HashMap<String, String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let name = e.name.clone();
|
||||
for (k, v) in e.attrs.iter_mut() {
|
||||
if is_rel_attr(k)
|
||||
if xml::is_rel_attr(&name, k)
|
||||
&& let Some(n) = ids.get(v.as_str())
|
||||
{
|
||||
*v = n.clone();
|
||||
@@ -642,6 +690,80 @@ fn patch_rel_ids(bytes: &[u8], ids: &HashMap<String, String>) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `bytes` (kept XML) without the personal information and dates the privacy settings remove, as
|
||||
/// the writer leaves them out of the parts it writes: annotation authors become "Author" and
|
||||
/// comment initials "A" (`names`); annotation dates and UTC dates go (`dates`); and with `names`,
|
||||
/// a people part's authors become "Author" and their presence information goes. Attributes are
|
||||
/// matched by namespace, so any prefix is caught. `None` when the markup can't be rewritten
|
||||
/// (malformed, or not UTF-8).
|
||||
fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::events::attributes::Attribute;
|
||||
use quick_xml::name::{QName, ResolveResult};
|
||||
use quick_xml::reader::NsReader;
|
||||
use quick_xml::writer::Writer;
|
||||
|
||||
const WML: &[u8] = b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const WML_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
|
||||
const W15: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
|
||||
// UTF-16 (or other wide encodings) would pass through unread.
|
||||
if bytes.contains(&0) || bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
|
||||
return None;
|
||||
}
|
||||
let ns_is = |r: &ResolveResult, ns: &[u8]| matches!(r, ResolveResult::Bound(n) if n.as_ref() == ns);
|
||||
let wml = |r: &ResolveResult| ns_is(r, WML) || ns_is(r, WML_STRICT);
|
||||
let mut r = NsReader::from_reader(bytes);
|
||||
r.config_mut().check_end_names = false;
|
||||
let mut out = Writer::new(Vec::with_capacity(bytes.len()));
|
||||
let mut buf = Vec::new();
|
||||
let mut skip = Vec::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).ok()?;
|
||||
let (in_wml, in_w15) = (wml(&res), ns_is(&res, W15));
|
||||
let ev = match ev {
|
||||
Event::Eof => break,
|
||||
Event::Start(ref e) | Event::Empty(ref e) if names && in_w15 && e.local_name().as_ref() == b"presenceInfo" => {
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
let end = e.name().as_ref().to_vec();
|
||||
r.read_to_end_into(QName(&end), &mut skip).ok()?;
|
||||
skip.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let mut el = e.to_owned();
|
||||
el.clear_attributes();
|
||||
for a in e.attributes().with_checks(false) {
|
||||
let a = a.ok()?;
|
||||
let (ares, local) = r.resolver_mut().resolve_attribute(a.key);
|
||||
// An unprefixed attribute of a WordprocessingML element is read as its own.
|
||||
let wml_attr = wml(&ares) || (matches!(ares, ResolveResult::Unbound) && in_wml);
|
||||
let value: Option<&[u8]> = match local.as_ref() {
|
||||
b"author" if names && (wml_attr || ns_is(&ares, W15)) => Some(super::ANONYMOUS_AUTHOR.as_bytes()),
|
||||
b"initials" if names && wml_attr => Some(super::ANONYMOUS_INITIALS.as_bytes()),
|
||||
b"date" if dates && wml_attr => None,
|
||||
b"dateUtc" if dates => None,
|
||||
_ => {
|
||||
el.push_attribute(a);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(v) = value {
|
||||
el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(v) });
|
||||
}
|
||||
}
|
||||
if matches!(ev, Event::Start(_)) { Event::Start(el) } else { Event::Empty(el) }
|
||||
}
|
||||
ev => ev,
|
||||
};
|
||||
out.write_event(ev).ok()?;
|
||||
}
|
||||
Some(out.into_inner())
|
||||
}
|
||||
|
||||
/// A VML `style` with the object's size, rotation and flips, and its offset when it floats
|
||||
/// (`position:absolute`).
|
||||
fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
|
||||
@@ -684,10 +806,67 @@ fn pt(v: f32) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a
|
||||
/// dot, or encoded separators.
|
||||
fn valid_part_name(name: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
for seg in name.split('/') {
|
||||
let mut seg = seg.replace('\\', "_");
|
||||
for enc in ["%2f", "%5c"] {
|
||||
while let Some(i) = seg.to_ascii_lowercase().find(enc) {
|
||||
seg.replace_range(i..i + 3, "_");
|
||||
}
|
||||
}
|
||||
if seg.is_empty() || seg == "." || seg == ".." {
|
||||
continue;
|
||||
}
|
||||
// A segment can't end in a dot either (`word/a.`).
|
||||
if seg.ends_with('.') {
|
||||
seg = format!("{}_", seg.trim_end_matches('.'));
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
// out without a word (review of #3872).
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, MAX_SERIES};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||||
use wordcraft_doc::{CharProps, InlineObject, Pos};
|
||||
let chart = |series: usize| {
|
||||
let spec = ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: vec!["a".into(), "b".into()],
|
||||
series: (0..series).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 2] }).collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
data_labels: false,
|
||||
};
|
||||
let graphic = Graphic { kind: GraphicKind::Chart, w: 100.0, h: 100.0, chart: Some(Arc::new(spec)), ..Default::default() };
|
||||
InlineObject::Graphic { w: 100.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }
|
||||
};
|
||||
let save = |charts: Vec<InlineObject>| {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
for c in charts {
|
||||
doc.insert_object(&Pos::body(0, 0), c, &CharProps::default()).unwrap();
|
||||
}
|
||||
crate::test_log::warnings(|| crate::write(&doc).unwrap()).1
|
||||
};
|
||||
let cut = |w: &Vec<String>| w.iter().filter(|w| w.contains("chart")).count();
|
||||
let warned = save(vec![chart(MAX_SERIES + 1), chart(MAX_SERIES + 5), chart(2)]);
|
||||
assert_eq!(cut(&warned), 1, "one warning for the save, not one per chart: {warned:?}");
|
||||
let warned = save(vec![chart(3)]);
|
||||
assert_eq!(cut(&warned), 0, "nothing left out: {warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_part_drawing_ids_are_rewritten_once() {
|
||||
let ids: HashMap<String, String> = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect();
|
||||
@@ -714,4 +893,16 @@ mod tests {
|
||||
// Unturned: no rotation or flip left behind.
|
||||
assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_part_names_are_valid_opc_names() {
|
||||
let doc = wordcraft_doc::Document::default();
|
||||
let mut w = EmbedWriter::new(&doc);
|
||||
for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] {
|
||||
let name = w.alloc(orig);
|
||||
assert!(!name.contains('\\'), "{name}");
|
||||
assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}");
|
||||
assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@ pub(crate) struct Writer<'d> {
|
||||
used_media: std::collections::BTreeSet<String>,
|
||||
/// Bounds writing text boxes inside text boxes (as layout shows them).
|
||||
boxes: wordcraft_doc::BoxBudget,
|
||||
/// Text box stories already written: a story shown by many shapes is written once, the other
|
||||
/// shapes get an empty box, so the file doesn't grow as shapes × story size.
|
||||
written_boxes: std::collections::HashSet<u32>,
|
||||
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
|
||||
embeds: embed::EmbedWriter<'d>,
|
||||
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
|
||||
@@ -175,6 +178,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
inline_controls: Vec::new(),
|
||||
used_media: Default::default(),
|
||||
boxes: wordcraft_doc::BoxBudget::default(),
|
||||
written_boxes: std::collections::HashSet::new(),
|
||||
embeds: embed::EmbedWriter::new(doc),
|
||||
txbx_links: doc
|
||||
.text_box_chains()
|
||||
@@ -268,7 +272,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
|
||||
// The glossary: kept verbatim while its entries are as read and its parts were kept, else
|
||||
// written from the model (before the notes: references its entries hold are collected too).
|
||||
if glossary::edited(doc) || (!doc.glossary.is_empty() && !wr.embeds.kept_glossary()) {
|
||||
// When the privacy settings ask for changes, its kept parts are written without the names and
|
||||
// dates of its tracked changes and comments (#3692), or from the model if they can't be.
|
||||
let (names, dates) = (doc.settings.remove_personal_info, doc.settings.remove_date_time);
|
||||
if glossary::edited(doc)
|
||||
|| (!doc.glossary.is_empty() && !wr.embeds.kept_glossary())
|
||||
|| ((names || dates) && !wr.embeds.scrub_glossary(names, dates))
|
||||
{
|
||||
wr.embeds.drop_glossary = true;
|
||||
wr.glossary(&mut entries, &mut overrides, &mut rels);
|
||||
}
|
||||
@@ -716,7 +726,15 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
|
||||
w.open("w:docDefaults", &[]);
|
||||
w.open("w:rPrDefault", &[]);
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(&mut w, &s.default_chr);
|
||||
if s.default_chr.size.is_none() {
|
||||
// The reader reads an omitted `w:sz` as 10 pt, but the resolver falls back to
|
||||
// DEFAULT_FONT_SIZE, so writing nothing would change the size on reopening.
|
||||
let mut chr = s.default_chr.clone();
|
||||
chr.size = Some(wordcraft_doc::props::DEFAULT_FONT_SIZE);
|
||||
props::rpr_inner(&mut w, &chr);
|
||||
} else {
|
||||
props::rpr_inner(&mut w, &s.default_chr);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
w.close("w:rPrDefault");
|
||||
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
|
||||
@@ -1071,6 +1089,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
}
|
||||
w.open("w:compat", &[]);
|
||||
// CT_Compat: the older switches come before the `w:compatSetting`s.
|
||||
if doc.settings.do_not_expand_shift_return {
|
||||
w.empty("w:doNotExpandShiftReturn", &[]);
|
||||
}
|
||||
if doc.settings.sum_paragraph_spacing {
|
||||
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
|
||||
}
|
||||
@@ -1123,9 +1144,9 @@ fn write_comment_ids(
|
||||
},
|
||||
};
|
||||
used.insert(durable.clone());
|
||||
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
|
||||
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
|
||||
// Remove date and time on save (#1062): no comment date here either.
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
|
||||
rows.push((pid.clone(), durable, utc));
|
||||
}
|
||||
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
|
||||
@@ -1216,7 +1237,7 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
|
||||
/// The author the saved file gives a comment or tracked change when the document removes personal
|
||||
/// information on save (`w:removePersonalInformation`), as Word does.
|
||||
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
|
||||
const ANONYMOUS_INITIALS: &str = "A";
|
||||
pub(crate) const ANONYMOUS_INITIALS: &str = "A";
|
||||
|
||||
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
|
||||
/// personal information on save, "Unknown" when it has none.
|
||||
|
||||
@@ -153,11 +153,26 @@ struct Child {
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// Byte index of the `>` that ends the tag at the start of `s`, skipping quoted attribute values.
|
||||
fn tag_end(s: &str) -> Option<usize> {
|
||||
let mut quote = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(None, '>') => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The root start tag's span end and the root's direct children, or `None` for anything the
|
||||
/// scanner doesn't expect (CDATA, DOCTYPE, a different root).
|
||||
fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
|
||||
let open = xml.find(&format!("<{root}"))?;
|
||||
let open_end = open + xml.get(open..)?.find('>')? + 1;
|
||||
let open_end = open + tag_end(xml.get(open..)?)? + 1;
|
||||
if xml.get(open_end - 2..open_end)? == "/>" {
|
||||
return None;
|
||||
}
|
||||
@@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
|
||||
i = at + tail.find("?>")? + 2;
|
||||
continue;
|
||||
}
|
||||
let gt = at + tail.find('>')? + 1;
|
||||
let gt = at + tag_end(tail)? + 1;
|
||||
let tag = xml.get(at..gt)?;
|
||||
if tag.starts_with("</") {
|
||||
depth = depth.checked_sub(1)?;
|
||||
@@ -343,6 +358,18 @@ mod tests {
|
||||
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() {
|
||||
for v in [r#"w:val="x>y""#, "w:val='x>y'"] {
|
||||
let orig = format!(
|
||||
r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:docVar w:name="a" {v}/><w:docVar w:name="b" {v}></w:docVar></w:settings>"#
|
||||
);
|
||||
assert!(has_unowned(orig.as_bytes()), "{v}");
|
||||
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
|
||||
assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compat_block_without_a_mode_gets_the_generated_one() {
|
||||
let orig = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:compat><w:useFELayout/></w:compat></w:settings>"#;
|
||||
|
||||
@@ -29,6 +29,43 @@ enum Piece<'a> {
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
w.raw(TOC_SDT_OPEN);
|
||||
self.toc_hold_end = true;
|
||||
}
|
||||
self.toc_begin_here = toc_field_open && i == start + 1;
|
||||
self.toc_end_here = toc_field_open && i == end;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
// `field` holds the heading's TOC field back for the first entry.
|
||||
self.toc_hold_end = false;
|
||||
*toc_field_open = self.toc_field.is_some();
|
||||
}
|
||||
if i == end {
|
||||
w.raw("</w:sdtContent></w:sdt>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
|
||||
// A header with no paragraph of its own before: the watermark goes in this one.
|
||||
if depth == 0
|
||||
&& let Some(run) = self.pending_watermark.take()
|
||||
{
|
||||
w.raw("<w:p>");
|
||||
w.raw(&run);
|
||||
w.raw("</w:p>");
|
||||
} else {
|
||||
w.raw("<w:p/>");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a block list. `top` = body level (sections allowed).
|
||||
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
@@ -43,14 +80,7 @@ impl Writer<'_> {
|
||||
for c in plan.opens.get(i).into_iter().flatten() {
|
||||
super::sdt::open(w, c);
|
||||
}
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
w.raw(TOC_SDT_OPEN);
|
||||
self.toc_hold_end = true;
|
||||
}
|
||||
self.toc_begin_here = toc_field_open && i == start + 1;
|
||||
self.toc_end_here = toc_field_open && i == end;
|
||||
}
|
||||
self.update_toc_before_block(i, toc, toc_field_open, w);
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
self.inline_controls = plan.inline_in(i);
|
||||
@@ -58,16 +88,7 @@ impl Writer<'_> {
|
||||
}
|
||||
Block::Table(t) => self.table(w, t, rels, depth),
|
||||
}
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
// `field` holds the heading's TOC field back for the first entry.
|
||||
self.toc_hold_end = false;
|
||||
toc_field_open = self.toc_field.is_some();
|
||||
}
|
||||
if i == end {
|
||||
w.raw("</w:sdtContent></w:sdt>");
|
||||
}
|
||||
}
|
||||
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
|
||||
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
|
||||
super::sdt::close(w);
|
||||
}
|
||||
@@ -76,16 +97,7 @@ impl Writer<'_> {
|
||||
self.move_ranges(w, &[]);
|
||||
}
|
||||
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
|
||||
// A header with no paragraph of its own before: the watermark goes in this one.
|
||||
if depth == 0
|
||||
&& let Some(run) = self.pending_watermark.take()
|
||||
{
|
||||
w.raw("<w:p>");
|
||||
w.raw(&run);
|
||||
w.raw("</w:p>");
|
||||
} else {
|
||||
w.raw("<w:p/>");
|
||||
}
|
||||
self.write_trailing_para(w, depth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +170,23 @@ impl Writer<'_> {
|
||||
self.para_inner(w, p, rels, top, depth, false);
|
||||
}
|
||||
|
||||
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
|
||||
w.open("w:rPr", &[]);
|
||||
for (idx, tag) in mark_revs {
|
||||
let (id, author, date) = self.rev_attrs(*idx);
|
||||
if date.is_empty() {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
|
||||
} else {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
|
||||
}
|
||||
}
|
||||
rpr_inner(w, mark);
|
||||
if let Some(ch) = mark.fmt_change.as_deref() {
|
||||
self.rpr_change(w, ch);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
}
|
||||
|
||||
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
|
||||
let para_id = self.take_para_id(p);
|
||||
match ¶_id {
|
||||
@@ -185,20 +214,7 @@ impl Writer<'_> {
|
||||
});
|
||||
ppr_inner(w, &pp, framed, num_change.as_ref());
|
||||
if has_mark {
|
||||
w.open("w:rPr", &[]);
|
||||
for (idx, tag) in mark_revs {
|
||||
let (id, author, date) = self.rev_attrs(idx);
|
||||
if date.is_empty() {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
|
||||
} else {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
|
||||
}
|
||||
}
|
||||
rpr_inner(w, &p.mark);
|
||||
if let Some(ch) = p.mark.fmt_change.as_deref() {
|
||||
self.rpr_change(w, ch);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
self.write_mark_rpr(w, &p.mark, &mark_revs);
|
||||
}
|
||||
if let Some(s) = section {
|
||||
self.sectpr(w, s, rels);
|
||||
@@ -1053,6 +1069,9 @@ impl Writer<'_> {
|
||||
ShapeKind::ElbowConnector => "bentConnector3",
|
||||
ShapeKind::CurvedConnector => "curvedConnector3",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::ArrowLeft => "leftArrow",
|
||||
ShapeKind::ArrowUp => "upArrow",
|
||||
ShapeKind::ArrowDown => "downArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
@@ -1078,7 +1097,8 @@ impl Writer<'_> {
|
||||
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())] };
|
||||
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
|
||||
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
|
||||
w.open("a:ln", ln);
|
||||
// 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);
|
||||
@@ -1103,7 +1123,9 @@ impl Writer<'_> {
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
let cap = extra.cap.ooxml();
|
||||
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
|
||||
w.open("a:ln", ln);
|
||||
w.empty("a:noFill", &[]);
|
||||
line_dash(w, &extra.dash);
|
||||
w.close("a:ln");
|
||||
@@ -1126,8 +1148,12 @@ impl Writer<'_> {
|
||||
None => w.open("wps:txbx", &[]),
|
||||
}
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
if self.written_boxes.insert(id) {
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
} else {
|
||||
w.empty("w:p", &[]);
|
||||
}
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
@@ -1697,12 +1723,11 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
|
||||
/// 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.
|
||||
/// The heading may end a section: entries in columns follow a continuous break (#3889).
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
let start = bl.iter().position(|b| {
|
||||
b.as_para().is_some_and(|p| {
|
||||
p.section.is_none()
|
||||
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
|
||||
})
|
||||
b.as_para()
|
||||
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
|
||||
})?;
|
||||
let entries = bl[start + 1..]
|
||||
.iter()
|
||||
|
||||
@@ -23,8 +23,21 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
let text = text(wm)?;
|
||||
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();
|
||||
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
|
||||
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
|
||||
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
|
||||
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
|
||||
// double-quoted string, so it is kept.
|
||||
let mut font = String::with_capacity(wm.font.len());
|
||||
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
|
||||
match c {
|
||||
'"' => font.push_str(r"\22 "),
|
||||
';' => font.push_str(r"\3b "),
|
||||
':' => font.push_str(r"\3a "),
|
||||
'\\' => font.push_str(r"\5c "),
|
||||
c => font.push(c),
|
||||
}
|
||||
}
|
||||
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
|
||||
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
|
||||
let style = format!(
|
||||
|
||||
+142
-4
@@ -15,6 +15,8 @@ use crate::DocxError;
|
||||
pub const MAX_DEPTH: usize = 256;
|
||||
/// Maximum number of elements in one part.
|
||||
pub const MAX_ELEMENTS: usize = 8_000_000;
|
||||
/// Maximum number of attributes on one element (#3702).
|
||||
pub const MAX_ATTRS: usize = 1024;
|
||||
|
||||
/// Known namespaces and the canonical prefix we use for them (read and write).
|
||||
pub const NAMESPACES: &[(&str, &str)] = &[
|
||||
@@ -116,6 +118,52 @@ pub enum Node {
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Elements whose markup names a relationship: the id is in an attribute of the relationship
|
||||
/// namespace (`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`…) or VML's `o:relid`, and — from a
|
||||
/// producer that left the prefix out — in the unqualified attribute of the same local name, the
|
||||
/// alias [`El::attr`] accepts. On any other element an unqualified `id` is the element's own
|
||||
/// (a drawing's `wp:docPr`, a picture's `pic:cNvPr`, a VML shape, a page background) and names
|
||||
/// no part.
|
||||
const REL_ELEMENTS: &[&str] = &[
|
||||
"a:blip",
|
||||
"a:hlinkClick",
|
||||
"a:hlinkHover",
|
||||
"a:audioFile",
|
||||
"a:videoFile",
|
||||
"c:chart",
|
||||
"cx:chart",
|
||||
"dgm:relIds",
|
||||
"o:OLEObject",
|
||||
"v:fill",
|
||||
"v:imagedata",
|
||||
"w:attachedTemplate",
|
||||
"w:control",
|
||||
"w:embedBold",
|
||||
"w:embedBoldItalic",
|
||||
"w:embedItalic",
|
||||
"w:embedRegular",
|
||||
"w:footerReference",
|
||||
"w:headerReference",
|
||||
"w:hyperlink",
|
||||
"w:object",
|
||||
"w:subDoc",
|
||||
"w14:contentPart",
|
||||
];
|
||||
|
||||
/// The local names a relationship attribute has, so an unqualified one is recognised as the
|
||||
/// relationship id it names.
|
||||
const REL_ALIASES: &[&str] = &["id", "embed", "link", "pict", "dm", "lo", "qs", "cs", "relid"];
|
||||
|
||||
/// Is the attribute `k` of the element named `element` a relationship id?
|
||||
///
|
||||
/// The relationship namespace (`r:`) and VML's `o:relid` always are. An unqualified attribute
|
||||
/// is one only on an element that carries a relationship ([`REL_ELEMENTS`]) and only with the
|
||||
/// local name of a relationship attribute ([`REL_ALIASES`]) — the alias [`El::attr`] accepts.
|
||||
/// Everywhere else an unqualified `id` is the element's own and names no part.
|
||||
pub fn is_rel_attr(element: &str, k: &str) -> bool {
|
||||
k.starts_with("r:") || k == "o:relid" || (REL_ELEMENTS.contains(&element) && REL_ALIASES.contains(&k))
|
||||
}
|
||||
|
||||
/// An element with canonical-prefixed name and attributes.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct El {
|
||||
@@ -263,6 +311,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
|
||||
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
|
||||
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, extra, false)
|
||||
}
|
||||
|
||||
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
|
||||
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
|
||||
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, &[], true)
|
||||
}
|
||||
|
||||
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
|
||||
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
|
||||
let transcoded = utf16_to_utf8(bytes);
|
||||
let bytes = match &transcoded {
|
||||
@@ -275,7 +333,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
c.trim_text_start = false;
|
||||
c.trim_text_end = false;
|
||||
c.expand_empty_elements = false;
|
||||
c.check_end_names = false;
|
||||
c.check_end_names = strict;
|
||||
}
|
||||
let mut stack: Vec<El> = Vec::new();
|
||||
let mut root: Option<El> = None;
|
||||
@@ -290,6 +348,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
};
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
if strict && root.is_some() && stack.is_empty() {
|
||||
return Err(DocxError::Xml("more than one root element".into()));
|
||||
}
|
||||
count += 1;
|
||||
if count > MAX_ELEMENTS {
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
@@ -301,6 +362,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
for a in e.attributes().with_checks(false) {
|
||||
if el.attrs.len() >= MAX_ATTRS {
|
||||
return Err(DocxError::Limit("too many attributes on one element".into()));
|
||||
}
|
||||
let Ok(a) = a else { continue };
|
||||
let key = a.key;
|
||||
let raw_key = key.as_ref();
|
||||
@@ -377,18 +441,23 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(c) = ch {
|
||||
push_text(top, &c);
|
||||
match ch {
|
||||
Some(c) => push_text(top, &c),
|
||||
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
if root.is_some() && stack.is_empty() {
|
||||
if !strict && root.is_some() && stack.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if strict && !stack.is_empty() {
|
||||
return Err(DocxError::Xml("an element is not closed".into()));
|
||||
}
|
||||
// Unclosed elements at EOF: close them (lenient).
|
||||
while let Some(el) = stack.pop() {
|
||||
attach(&mut stack, &mut root, el);
|
||||
@@ -508,6 +577,42 @@ pub fn body_ns() -> Vec<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
|
||||
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
|
||||
for xml in bad {
|
||||
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
|
||||
}
|
||||
assert!(
|
||||
parse_strict(
|
||||
b"<a>x & y</a>
|
||||
"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
assert!(is_rel_attr("w:hyperlink", "r:id"));
|
||||
assert!(is_rel_attr("v:imagedata", "o:relid"));
|
||||
// The unqualified alias `El::attr` accepts, on an element that carries a relationship.
|
||||
assert!(is_rel_attr("w:hyperlink", "id"));
|
||||
assert!(is_rel_attr("v:imagedata", "id"));
|
||||
assert!(is_rel_attr("a:blip", "embed"));
|
||||
assert!(is_rel_attr("dgm:relIds", "dm"));
|
||||
// An ordinary `id` — the element's own, naming no part — is not a relationship.
|
||||
assert!(!is_rel_attr("v:shape", "id"));
|
||||
assert!(!is_rel_attr("pic:cNvPr", "id"));
|
||||
assert!(!is_rel_attr("wp:docPr", "id"));
|
||||
assert!(!is_rel_attr("v:background", "id"));
|
||||
// An attribute that is not a relationship attribute at all.
|
||||
assert!(!is_rel_attr("w:hyperlink", "anchor"));
|
||||
assert!(!is_rel_attr("v:imagedata", "title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_parts_parse_and_odd_ones_never_panic() {
|
||||
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
|
||||
@@ -569,6 +674,39 @@ mod tests {
|
||||
assert!(parse(s.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
|
||||
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
|
||||
#[test]
|
||||
fn too_many_attributes_on_one_element_is_capped() {
|
||||
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
|
||||
const CAP: usize = 1024;
|
||||
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
|
||||
let duplicate = " a=\"\"".repeat(200_000);
|
||||
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
|
||||
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
|
||||
match parse(x.as_bytes()) {
|
||||
Err(DocxError::Limit(_)) => {}
|
||||
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
|
||||
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
|
||||
}
|
||||
}
|
||||
// Real-world attribute counts are untouched.
|
||||
let x = format!(
|
||||
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
|
||||
NAMESPACES[0].1
|
||||
);
|
||||
let p = parse(x.as_bytes()).unwrap();
|
||||
assert_eq!(p.attrs.len(), 4);
|
||||
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
|
||||
assert_eq!(fonts.attrs.len(), 9);
|
||||
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
|
||||
// Far more than any real producer writes, still well under any sane cap.
|
||||
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
|
||||
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
|
||||
assert_eq!(p.attrs.len(), 256);
|
||||
assert_eq!(p.attr("a255"), Some("255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_error_not_panic() {
|
||||
assert!(parse(b"").is_err());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user