mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 17:04:43 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0c8b7b0d | ||
|
|
7290677207 |
@@ -1,3 +0,0 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -1 +0,0 @@
|
||||
craft-i18n.rev text eol=lf
|
||||
+47
-13
@@ -1,7 +1,8 @@
|
||||
# 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]
|
||||
@@ -11,18 +12,51 @@ 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.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
|
||||
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
|
||||
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
# (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 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.
|
||||
# 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.
|
||||
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:
|
||||
@@ -28,4 +29,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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
||||
@@ -21,27 +21,3 @@ jobs:
|
||||
lint:
|
||||
# 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/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,16 +19,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
# 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/release.yml@main
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -11,8 +11,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
@@ -31,5 +31,3 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
install-relative-path: Programs/ArtCraft/WordCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -50,7 +50,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
@@ -82,8 +82,3 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+15
-3
@@ -8,8 +8,6 @@ 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) |
|
||||
@@ -69,4 +67,18 @@ 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 |
|
||||
| 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 |
|
||||
| `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 |
|
||||
|
||||
@@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
|
||||
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
|
||||
|
||||
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
|
||||
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
|
||||
@@ -49,7 +49,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
@@ -63,8 +63,3 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+371
-1995
File diff suppressed because it is too large
Load Diff
+11
-44
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -52,19 +52,13 @@ 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
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.9"
|
||||
miniz_oxide = "0.8"
|
||||
cfb = "0.15"
|
||||
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
|
||||
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
|
||||
@@ -76,44 +70,32 @@ aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
base64 = "0.23"
|
||||
base64 = "0.22"
|
||||
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.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"
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "deny"
|
||||
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
|
||||
@@ -123,19 +105,4 @@ 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
|
||||
|
||||
@@ -95,32 +95,14 @@ 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.
|
||||
- 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.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
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.
|
||||
|
||||
@@ -227,11 +227,7 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
| 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` |
|
||||
|
||||
The installer is one dual-scope package. By default it installs for your account only,
|
||||
into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing
|
||||
"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build)
|
||||
and asks for an administrator. Install only one copy: a copy for another scope is refused, so
|
||||
uninstall first to change who WordCraft is installed for. Installers and executables are code-signed.
|
||||
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
|
||||
|
||||
+3
-172
File diff suppressed because one or more lines are too long
-26
@@ -1,26 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security problems privately through GitHub:
|
||||
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
|
||||
(Security tab › Advisories › Report a vulnerability).
|
||||
|
||||
Don't open a public issue or pull request for a vulnerability until a fix is released.
|
||||
|
||||
Useful to include:
|
||||
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
|
||||
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
|
||||
- steps or a sample file that reproduce it.
|
||||
|
||||
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
|
||||
|
||||
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
|
||||
+543
-89
@@ -36,11 +36,7 @@ USAGE:
|
||||
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
|
||||
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
|
||||
formats follow the extension (docx and pdf in one run);
|
||||
relative paths resolve against the script's folder.
|
||||
file.new and file.open open another document and
|
||||
make it active; later commands and the saves work on
|
||||
the active one; view.switchWindows lists the open
|
||||
documents and with {\"id\": N} makes one active
|
||||
relative paths resolve against the script's folder
|
||||
wordcraft-cli commands [--json] list every command
|
||||
wordcraft-cli parity [--markdown] feature-catalog parity
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
@@ -184,8 +180,6 @@ 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}"))
|
||||
}
|
||||
@@ -383,37 +377,6 @@ 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()
|
||||
}
|
||||
|
||||
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||
match arg_value(rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => {
|
||||
let source = script_path.unwrap_or(Path::new(&path));
|
||||
Ok(Some(script::load(source)?))
|
||||
}
|
||||
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option<String>, Option<String>) {
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.and_then(|loaded| loaded.file.clone());
|
||||
from_template = script.and_then(|loaded| loaded.template.clone());
|
||||
}
|
||||
(from_file, from_template)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -495,19 +458,24 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let page = parse_page_arg("run", &rest)?;
|
||||
let scale = parse_scale_arg("run", &rest)?;
|
||||
let password = password_from(&rest)?;
|
||||
let script = load_run_script(&rest, script_path.as_deref())?;
|
||||
let (from_file, from_template) = run_source_paths(&rest, script.as_ref());
|
||||
let script = match arg_value(&rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
|
||||
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
|
||||
_ => None,
|
||||
};
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.as_ref().and_then(|c| c.file.clone());
|
||||
from_template = script.as_ref().and_then(|c| c.template.clone());
|
||||
}
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
// `file.new` opens beside the session's first document, which is then closed: the
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
};
|
||||
@@ -612,27 +580,11 @@ 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) => {
|
||||
// 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))
|
||||
}
|
||||
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}");
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
None => {
|
||||
let mut headless = wordcraft_mcp::Headless::default();
|
||||
@@ -679,14 +631,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!("← {} {}", 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)))));
|
||||
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
|
||||
eprintln!("→ {}", 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: {}", escape_peer(a));
|
||||
eprintln!("Zotero: {a}");
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
@@ -709,27 +661,6 @@ 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",
|
||||
@@ -768,4 +699,527 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
|
||||
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(
|
||||
validate_options(
|
||||
"zotero",
|
||||
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
|
||||
ZOTERO_OPTIONS
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_value_is_never_read_as_a_path() {
|
||||
// `--password`'s value sits between the paths and would be taken for one without
|
||||
// `positionals`, so `render` would write its picture over a file called after the password.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
// A bare flag takes no value, so the next argument stays a path.
|
||||
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
|
||||
// `convert` is the one with a value-taking flag between the two paths.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_comes_from_an_option_a_file_or_the_environment() {
|
||||
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
|
||||
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
|
||||
// `--password` wins over the other two sources.
|
||||
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_file_loses_only_its_trailing_newline() {
|
||||
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
|
||||
std::fs::write(&path, "s3cret\r\n").unwrap();
|
||||
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(got, Ok(Some("s3cret".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_password_file_is_named_without_the_password() {
|
||||
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
|
||||
assert!(err.contains("--password-file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
|
||||
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
|
||||
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
|
||||
}
|
||||
assert!(!wants_help("frobnicate", &args(&["--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_exactly_the_subcommands() {
|
||||
let listed: Vec<&str> = USAGE
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
|
||||
.filter(|word| !word.starts_with(['-', '[']))
|
||||
.collect();
|
||||
assert_eq!(listed, SUBCOMMANDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_bullet_and_number_labels() {
|
||||
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_names_the_headless_session() {
|
||||
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
|
||||
assert_eq!(s.author, "Claude (copyedit)");
|
||||
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
|
||||
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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
|
||||
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
std::fs::create_dir_all(base.join("out")).unwrap();
|
||||
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
|
||||
|
||||
let (roots, left) =
|
||||
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
|
||||
.unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert_eq!(left, args(&["--author", "Claude"]));
|
||||
assert!(roots.read_root().is_some() && roots.write_root().is_some());
|
||||
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert!(roots.write_root().is_none());
|
||||
assert!(
|
||||
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
|
||||
);
|
||||
|
||||
// No flags: nothing is confined and the arguments pass through.
|
||||
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
|
||||
assert!(roots.is_none());
|
||||
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
|
||||
for bad in [
|
||||
args(&["--automation-read-root"]),
|
||||
args(&["--automation-read-root", "--author"]),
|
||||
args(&["--automation-write-root="]),
|
||||
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
|
||||
] {
|
||||
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
|
||||
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
|
||||
// Through the command line: an error, not a server that quietly runs unconfined.
|
||||
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
// `OPENS_FILE` and the commands' own arguments agree.
|
||||
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
|
||||
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
|
||||
.iter()
|
||||
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
|
||||
.collect();
|
||||
opening.push(vec!["run".into(), "--file".into(), missing()]);
|
||||
for raw in opening {
|
||||
let e = run(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("No such file"), "{raw:?}: {e}");
|
||||
}
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_are_an_error_with_connect() {
|
||||
let dir = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
|
||||
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
|
||||
Err("render: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
|
||||
Err("render: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
|
||||
Err("render: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
|
||||
Err("render: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
|
||||
Err("render: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
|
||||
Err("render: invalid --scale `-2`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
|
||||
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
|
||||
Err("render: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("render: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
|
||||
Err("run: --page requires a value".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
|
||||
Err("run: --scale requires a value".into())
|
||||
);
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
|
||||
Err("run: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
|
||||
Err("run: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
|
||||
Err("run: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
|
||||
Err("run: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
|
||||
Err("run: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
|
||||
Err("run: invalid --scale `-1`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
|
||||
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
|
||||
Err("run: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
|
||||
Err("run: duplicate --page option".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("run: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_and_run_valid_options_and_defaults_produce_files() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let docx_path = dir.join("sample.docx");
|
||||
// Create sample docx via `run --save sample.docx`
|
||||
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
|
||||
assert!(docx_path.exists());
|
||||
|
||||
let docx_str = docx_path.to_string_lossy().into_owned();
|
||||
|
||||
// 1. render with defaults
|
||||
let out_default = dir.join("out_default.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
|
||||
assert!(out_default.exists());
|
||||
|
||||
// 2. render with valid --page and --scale
|
||||
let out_valid = dir.join("out_valid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
|
||||
assert!(out_valid.exists());
|
||||
|
||||
// 3. render with different flag order
|
||||
let out_reordered = dir.join("out_reordered.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
|
||||
assert!(out_reordered.exists());
|
||||
|
||||
// 4. render failure leaves no output file
|
||||
let out_invalid = dir.join("out_invalid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
|
||||
assert!(!out_invalid.exists());
|
||||
|
||||
// 5. run --save out.png with defaults
|
||||
let run_default = dir.join("run_default.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
|
||||
assert!(run_default.exists());
|
||||
|
||||
// 6. run --save out.png with --page and --scale
|
||||
let run_valid = dir.join("run_valid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
|
||||
assert!(run_valid.exists());
|
||||
|
||||
// 7. run --save out.png with different flag order
|
||||
let run_reordered = dir.join("run_reordered.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
|
||||
assert!(run_reordered.exists());
|
||||
|
||||
// 8. run --save out.png failure leaves no output file
|
||||
let run_invalid = dir.join("run_invalid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
|
||||
assert!(!run_invalid.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
/// A scratch folder that removes itself, even when the test fails.
|
||||
struct ScriptDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for ScriptDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn script_dir(tag: &str) -> ScriptDir {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ScriptDir(dir)
|
||||
}
|
||||
|
||||
fn write_script(dir: &Path, text: &str) -> String {
|
||||
let p = dir.join("job.json");
|
||||
std::fs::write(&p, text).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
|
||||
let dir = script_dir("two");
|
||||
let script = write_script(
|
||||
&dir,
|
||||
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
|
||||
);
|
||||
run(&args(&["run", "--script", &script])).unwrap();
|
||||
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
|
||||
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_bad_output_writes_no_output() {
|
||||
let dir = script_dir("badout");
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err());
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_folder_as_output_writes_no_output() {
|
||||
let dir = script_dir("folderout");
|
||||
std::fs::create_dir(dir.join("b.pdf")).unwrap();
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("b.pdf"), "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = script_dir("latin1");
|
||||
let mut name = dir.as_os_str().to_os_string().into_vec();
|
||||
name.extend_from_slice(b"/caf\xe9.json");
|
||||
let path = PathBuf::from(OsString::from_vec(name));
|
||||
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
|
||||
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
|
||||
run(&raw).unwrap();
|
||||
assert!(dir.join("o.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_template_wins_over_the_scripts_file() {
|
||||
let dir = script_dir("source");
|
||||
let input = dir.join("in.docx");
|
||||
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
|
||||
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
|
||||
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
|
||||
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
|
||||
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
|
||||
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_value_missing_is_reported() {
|
||||
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
|
||||
assert!(e.contains("--script requires a value"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_from_a_stream_and_caps_its_size() {
|
||||
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
|
||||
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
|
||||
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
|
||||
let e = script::load_from(big, "-", None).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(script::load_from(&b"{"[..], "-", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_commands_run_before_cmd_flags() {
|
||||
let dir = script_dir("order");
|
||||
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
|
||||
let out = dir.join("o.txt");
|
||||
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_names_the_command_and_writes_nothing() {
|
||||
let dir = script_dir("fail");
|
||||
let script =
|
||||
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
|
||||
assert!(!dir.join("out.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_rejects_junk_empty_missing_and_huge_input() {
|
||||
let dir = script_dir("junk");
|
||||
for text in [
|
||||
"not json",
|
||||
"[]",
|
||||
"{}",
|
||||
r#"{"commands":[]}"#,
|
||||
r#"{"commands":[{"params":{}}]}"#,
|
||||
r#"{"commands":[1]}"#,
|
||||
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
|
||||
] {
|
||||
let script = write_script(&dir, text);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
|
||||
}
|
||||
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
|
||||
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
|
||||
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_parse_resolves_paths_against_the_base() {
|
||||
let s = script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base")))
|
||||
.unwrap();
|
||||
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
|
||||
assert_eq!(s.file, Some(joined("in.docx")));
|
||||
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
|
||||
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
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());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
|
||||
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(
|
||||
validate_options(
|
||||
"zotero",
|
||||
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
|
||||
ZOTERO_OPTIONS
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_value_is_never_read_as_a_path() {
|
||||
// `--password`'s value sits between the paths and would be taken for one without
|
||||
// `positionals`, so `render` would write its picture over a file called after the password.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
// A bare flag takes no value, so the next argument stays a path.
|
||||
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
|
||||
// `convert` is the one with a value-taking flag between the two paths.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_comes_from_an_option_a_file_or_the_environment() {
|
||||
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
|
||||
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
|
||||
// `--password` wins over the other two sources.
|
||||
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_file_loses_only_its_trailing_newline() {
|
||||
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
|
||||
std::fs::write(&path, "s3cret\r\n").unwrap();
|
||||
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(got, Ok(Some("s3cret".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_password_file_is_named_without_the_password() {
|
||||
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
|
||||
assert!(err.contains("--password-file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
|
||||
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
|
||||
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
|
||||
}
|
||||
assert!(!wants_help("frobnicate", &args(&["--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_exactly_the_subcommands() {
|
||||
let listed: Vec<&str> = USAGE
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
|
||||
.filter(|word| !word.starts_with(['-', '[']))
|
||||
.collect();
|
||||
assert_eq!(listed, SUBCOMMANDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_bullet_and_number_labels() {
|
||||
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_names_the_headless_session() {
|
||||
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
|
||||
assert_eq!(s.author, "Claude (copyedit)");
|
||||
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
|
||||
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]
|
||||
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
|
||||
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
std::fs::create_dir_all(base.join("out")).unwrap();
|
||||
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
|
||||
|
||||
let (roots, left) =
|
||||
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
|
||||
.unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert_eq!(left, args(&["--author", "Claude"]));
|
||||
assert!(roots.read_root().is_some() && roots.write_root().is_some());
|
||||
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert!(roots.write_root().is_none());
|
||||
assert!(roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent"));
|
||||
|
||||
// No flags: nothing is confined and the arguments pass through.
|
||||
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
|
||||
assert!(roots.is_none());
|
||||
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
|
||||
for bad in [
|
||||
args(&["--automation-read-root"]),
|
||||
args(&["--automation-read-root", "--author"]),
|
||||
args(&["--automation-write-root="]),
|
||||
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
|
||||
] {
|
||||
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
|
||||
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
|
||||
// Through the command line: an error, not a server that quietly runs unconfined.
|
||||
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
// `OPENS_FILE` and the commands' own arguments agree.
|
||||
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
|
||||
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
|
||||
.iter()
|
||||
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
|
||||
.collect();
|
||||
opening.push(vec!["run".into(), "--file".into(), missing()]);
|
||||
for raw in opening {
|
||||
let e = run(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("No such file"), "{raw:?}: {e}");
|
||||
}
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_are_an_error_with_connect() {
|
||||
let dir = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
|
||||
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
|
||||
Err("render: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])), Err("render: invalid --scale `banana`: not a number".into()));
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "0"])), Err("render: invalid --page `0`: page numbers start at 1".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])), Err("render: invalid --page `-1`: not a valid integer".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
|
||||
Err("render: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
|
||||
Err("render: invalid --scale `-2`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
|
||||
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
|
||||
Err("render: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])), Err("render: duplicate --scale option".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
|
||||
Err("run: --page requires a value".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
|
||||
Err("run: --scale requires a value".into())
|
||||
);
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
|
||||
Err("run: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
|
||||
Err("run: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
|
||||
Err("run: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
|
||||
Err("run: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
|
||||
Err("run: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
|
||||
Err("run: invalid --scale `-1`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
|
||||
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
|
||||
Err("run: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
|
||||
Err("run: duplicate --page option".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("run: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_and_run_valid_options_and_defaults_produce_files() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let docx_path = dir.join("sample.docx");
|
||||
// Create sample docx via `run --save sample.docx`
|
||||
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
|
||||
assert!(docx_path.exists());
|
||||
|
||||
let docx_str = docx_path.to_string_lossy().into_owned();
|
||||
|
||||
// 1. render with defaults
|
||||
let out_default = dir.join("out_default.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
|
||||
assert!(out_default.exists());
|
||||
|
||||
// 2. render with valid --page and --scale
|
||||
let out_valid = dir.join("out_valid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
|
||||
assert!(out_valid.exists());
|
||||
|
||||
// 3. render with different flag order
|
||||
let out_reordered = dir.join("out_reordered.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
|
||||
assert!(out_reordered.exists());
|
||||
|
||||
// 4. render failure leaves no output file
|
||||
let out_invalid = dir.join("out_invalid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
|
||||
assert!(!out_invalid.exists());
|
||||
|
||||
// 5. run --save out.png with defaults
|
||||
let run_default = dir.join("run_default.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
|
||||
assert!(run_default.exists());
|
||||
|
||||
// 6. run --save out.png with --page and --scale
|
||||
let run_valid = dir.join("run_valid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
|
||||
assert!(run_valid.exists());
|
||||
|
||||
// 7. run --save out.png with different flag order
|
||||
let run_reordered = dir.join("run_reordered.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
|
||||
assert!(run_reordered.exists());
|
||||
|
||||
// 8. run --save out.png failure leaves no output file
|
||||
let run_invalid = dir.join("run_invalid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
|
||||
assert!(!run_invalid.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
/// A scratch folder that removes itself, even when the test fails.
|
||||
struct ScriptDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for ScriptDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn script_dir(tag: &str) -> ScriptDir {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ScriptDir(dir)
|
||||
}
|
||||
|
||||
fn write_script(dir: &Path, text: &str) -> String {
|
||||
let p = dir.join("job.json");
|
||||
std::fs::write(&p, text).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
|
||||
let dir = script_dir("two");
|
||||
let script = write_script(
|
||||
&dir,
|
||||
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
|
||||
);
|
||||
run(&args(&["run", "--script", &script])).unwrap();
|
||||
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
|
||||
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_bad_output_writes_no_output() {
|
||||
let dir = script_dir("badout");
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err());
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_folder_as_output_writes_no_output() {
|
||||
let dir = script_dir("folderout");
|
||||
std::fs::create_dir(dir.join("b.pdf")).unwrap();
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("b.pdf"), "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
|
||||
/// (as in `tests/it/non_utf8_args.rs`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = script_dir("latin1");
|
||||
let mut name = dir.as_os_str().to_os_string().into_vec();
|
||||
name.extend_from_slice(b"/caf\xe9.json");
|
||||
let path = PathBuf::from(OsString::from_vec(name));
|
||||
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
|
||||
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
|
||||
run(&raw).unwrap();
|
||||
assert!(dir.join("o.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_template_wins_over_the_scripts_file() {
|
||||
let dir = script_dir("source");
|
||||
let input = dir.join("in.docx");
|
||||
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
|
||||
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
|
||||
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
|
||||
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
|
||||
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
|
||||
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_value_missing_is_reported() {
|
||||
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
|
||||
assert!(e.contains("--script requires a value"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_from_a_stream_and_caps_its_size() {
|
||||
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
|
||||
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
|
||||
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
|
||||
let e = script::load_from(big, "-", None).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(script::load_from(&b"{"[..], "-", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_commands_run_before_cmd_flags() {
|
||||
let dir = script_dir("order");
|
||||
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
|
||||
let out = dir.join("o.txt");
|
||||
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_names_the_command_and_writes_nothing() {
|
||||
let dir = script_dir("fail");
|
||||
let script =
|
||||
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
|
||||
assert!(!dir.join("out.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_rejects_junk_empty_missing_and_huge_input() {
|
||||
let dir = script_dir("junk");
|
||||
for text in [
|
||||
"not json",
|
||||
"[]",
|
||||
"{}",
|
||||
r#"{"commands":[]}"#,
|
||||
r#"{"commands":[{"params":{}}]}"#,
|
||||
r#"{"commands":[1]}"#,
|
||||
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
|
||||
] {
|
||||
let script = write_script(&dir, text);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
|
||||
}
|
||||
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
|
||||
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
|
||||
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_parse_resolves_paths_against_the_base() {
|
||||
let s =
|
||||
script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base"))).unwrap();
|
||||
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
|
||||
assert_eq!(s.file, Some(joined("in.docx")));
|
||||
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
|
||||
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
//! 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};
|
||||
@@ -1,39 +0,0 @@
|
||||
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
|
||||
//! another document and make it active, and `--save` saves the active one.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str], out: &Path) -> Output {
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.arg("run").args(args).arg("--save").arg(out);
|
||||
cli.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_saves_the_document_opened_last() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
|
||||
std::fs::write(&src, "Opened\n").unwrap();
|
||||
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
|
||||
|
||||
// A script that starts a new document and types into it saves that document.
|
||||
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
|
||||
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A file opened after typing is what is saved; the typed document stays out of it.
|
||||
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
|
||||
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A run starts with one open document.
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
|
||||
assert_eq!(fresh.trim(), "Fresh");
|
||||
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
|
||||
assert_eq!(reopened.trim(), "Opened");
|
||||
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
|
||||
let stdout = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#[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,6 +1,7 @@
|
||||
//! 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", "MessageEvent", "UrlSearchParams"] }
|
||||
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"] }
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
//! 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,9 +8,6 @@
|
||||
//! 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))]
|
||||
@@ -18,8 +15,6 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -25,35 +25,17 @@ 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, 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()));
|
||||
}
|
||||
Self { last: stored }
|
||||
}
|
||||
|
||||
/// 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 mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author.as_deref() == Some(host.as_str())
|
||||
{
|
||||
prefs.author = Some(own.clone());
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
@@ -78,29 +60,6 @@ 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,15 +27,9 @@ 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 bridge.is_none()
|
||||
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
|
||||
{
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -62,17 +56,12 @@ 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();
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let sample = query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
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);
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -85,31 +74,15 @@ pub fn start() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored);
|
||||
let 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(),
|
||||
@@ -123,10 +96,6 @@ 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 {
|
||||
@@ -146,10 +115,22 @@ 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);
|
||||
@@ -168,34 +149,11 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(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);
|
||||
}
|
||||
}
|
||||
@@ -462,7 +420,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(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -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", "clipboard", "wayland", "x11", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -73,16 +73,13 @@ 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
pollster = "1"
|
||||
pollster = "0.4"
|
||||
# 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 }
|
||||
|
||||
@@ -35,31 +35,26 @@ impl AppleEvents {
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which
|
||||
/// asks the same.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.open_file(p, None) {
|
||||
match app.run("file.open", serde_json::json!({"path": p})) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
app.report_open_failure(p, &e);
|
||||
// Backstage hides the status bar, so leave it to show the error.
|
||||
app.ui.backstage = false;
|
||||
log::warn!("{p}: {e}");
|
||||
let e = e.to_string();
|
||||
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
|
||||
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Quitting asks about every document with unsaved changes, each in its window; the
|
||||
// app closes once that's done (`quit_requested`).
|
||||
Event::Quit => {
|
||||
app.quit();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +95,7 @@ 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; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
/// why (#833). The UI checks the sizes before inserting anything.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -110,19 +109,6 @@ 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),
|
||||
@@ -201,7 +187,6 @@ 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() };
|
||||
@@ -220,31 +205,8 @@ mod platform {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// 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 encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
|
||||
@@ -36,8 +36,7 @@ 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 is only offered under Open With, and plain
|
||||
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
@@ -165,16 +164,6 @@ 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(std::ffi::OsStr::to_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
@@ -258,9 +258,7 @@ mod tests {
|
||||
fn an_instant_cancel_retries_then_falls_back() {
|
||||
let quick = Duration::from_millis(200);
|
||||
let slow_cancel = || async {
|
||||
// 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
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
|
||||
@@ -346,4 +346,339 @@ pub fn install_panic_hook() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"wordcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,wordcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let record = |level, target: &'static str, msg: &'static str| {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
};
|
||||
record(log::Level::Info, "wordcraft", "before the settings directory");
|
||||
record(log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
|
||||
record(log::Level::Debug, "wordcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
l.log(
|
||||
&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build(),
|
||||
);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.pending.is_empty() && sink.file.is_none());
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
|
||||
assert!(read(&path).contains("wordcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_messages_leave_out_quoted_text() {
|
||||
// What std says when a string is sliced inside a character: the string is the user's text.
|
||||
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
|
||||
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
|
||||
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
|
||||
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
|
||||
// An unpaired backtick hides the rest rather than risk leaking it.
|
||||
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
|
||||
assert_eq!(
|
||||
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
|
||||
"no quotes: index out of bounds: the len is 3 but the index is 5"
|
||||
);
|
||||
assert_eq!(redact_quoted(""), "");
|
||||
assert_eq!(redact_quoted("``"), "`…`");
|
||||
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_report_names_the_place_and_the_redacted_message() {
|
||||
assert_eq!(
|
||||
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
|
||||
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
|
||||
);
|
||||
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
|
||||
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"wordcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,wordcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let record = |level, target: &'static str, msg: &'static str| {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
};
|
||||
record(log::Level::Info, "wordcraft", "before the settings directory");
|
||||
record(log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
|
||||
record(log::Level::Debug, "wordcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
l.log(&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build());
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.pending.is_empty() && sink.file.is_none());
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
|
||||
assert!(read(&path).contains("wordcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_messages_leave_out_quoted_text() {
|
||||
// What std says when a string is sliced inside a character: the string is the user's text.
|
||||
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
|
||||
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
|
||||
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
|
||||
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
|
||||
// An unpaired backtick hides the rest rather than risk leaking it.
|
||||
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
|
||||
assert_eq!(
|
||||
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
|
||||
"no quotes: index out of bounds: the len is 3 but the index is 5"
|
||||
);
|
||||
assert_eq!(redact_quoted(""), "");
|
||||
assert_eq!(redact_quoted("``"), "`…`");
|
||||
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_report_names_the_place_and_the_redacted_message() {
|
||||
assert_eq!(
|
||||
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
|
||||
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
|
||||
);
|
||||
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
|
||||
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
|
||||
}
|
||||
+13
-101
@@ -27,66 +27,47 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
/// first frame that knows the monitor has checked it, the control server's key file (removed on
|
||||
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
|
||||
/// the documents opened from Finder.
|
||||
/// exit), the file dialogs the app asked for 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.5, &mut self.0, ctx);
|
||||
apple_events::poll(&self.4, &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)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
// Another document window's state is the one it last painted with: the host reports only
|
||||
// the root window's for this frame.
|
||||
let active = self.0.active_viewport();
|
||||
let tracked = if active == egui::ViewportId::ROOT {
|
||||
window
|
||||
} else {
|
||||
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
|
||||
};
|
||||
self.0.ui.window = WindowGeometry::track(prev, &tracked);
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
|
||||
if let Some(pos) = take_rescue(&mut self.1, &window) {
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
|
||||
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
if let Some(command) = maximize {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
// The window's close button: with other windows open only this window closes (the app
|
||||
// handles that while drawing it); the last one quits, asking first about unsaved changes
|
||||
// (`WordApp::close_requested`).
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
|
||||
if closing && !self.0.close_requested() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
@@ -104,13 +85,8 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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 raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -329,26 +305,6 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
|
||||
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
|
||||
/// the value after each. They are not files, and opening them as files fails.
|
||||
fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
let mut kept = Vec::with_capacity(args.len());
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a.starts_with("-psn_") {
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("-NS") || a.starts_with("-Apple") {
|
||||
// Its value, unless the next argument is another flag.
|
||||
args.next_if(|v| !v.starts_with('-'));
|
||||
continue;
|
||||
}
|
||||
kept.push(a);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
@@ -360,21 +316,11 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(&wordcraft_ui_egui::UPDATE_APP, &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();
|
||||
@@ -382,8 +328,6 @@ fn main() -> eframe::Result {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
|
||||
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
@@ -453,8 +397,6 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
@@ -487,7 +429,6 @@ 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
|
||||
@@ -506,7 +447,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
app.ui.window = restored;
|
||||
app.set_window_builder(window_builder);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(bar) = traffic_lights::title_bar() {
|
||||
@@ -528,7 +468,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
app.report_open_failure(&f, &e);
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
@@ -540,7 +480,6 @@ fn main() -> eframe::Result {
|
||||
Some(restored.unwrap_or_default()),
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
@@ -614,33 +553,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_arg_tests {
|
||||
use super::without_launch_args;
|
||||
|
||||
fn strip(args: &[&str]) -> Vec<String> {
|
||||
without_launch_args(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launch_arguments_are_not_files() {
|
||||
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
|
||||
// A flag with no value after it, or followed by another flag, takes nothing with it.
|
||||
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_arguments_are_kept() {
|
||||
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
|
||||
// Files whose names merely start like a launch argument keep their place when they are paths.
|
||||
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
|
||||
assert!(strip(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! 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, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
@@ -18,11 +13,11 @@ pub fn discover() -> Discover {
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -51,7 +46,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(std::sync::PoisonError::into_inner);
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
mod non_utf8_args;
|
||||
@@ -1,4 +1,5 @@
|
||||
//! 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;
|
||||
@@ -2,7 +2,3 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -175,18 +175,3 @@ display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.matstrange]
|
||||
sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
[people.TobyWu666]
|
||||
display_name = "tobywu"
|
||||
|
||||
[people.Bra1nZ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
@@ -8,15 +8,9 @@ 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 }
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
use super::{Arc, Deserialize, Paragraph, Serialize, Table};
|
||||
|
||||
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Block {
|
||||
Para(Paragraph),
|
||||
Table(Table),
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn as_para(&self) -> Option<&Paragraph> {
|
||||
match self {
|
||||
Block::Para(p) => Some(p),
|
||||
Block::Table(_) => None,
|
||||
}
|
||||
}
|
||||
pub fn as_table(&self) -> Option<&Table> {
|
||||
match self {
|
||||
Block::Table(t) => Some(t),
|
||||
Block::Para(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Blocks = Vec<Arc<Block>>;
|
||||
|
||||
/// Text boxes inside text boxes count (and show) this many levels deep.
|
||||
pub const MAX_TEXT_BOX_DEPTH: usize = 4;
|
||||
|
||||
/// Bounds the work of expanding text boxes inside text boxes (layout, walks, saving), whatever a
|
||||
/// document says: a box isn't expanded inside itself, nesting stops at [`MAX_TEXT_BOX_DEPTH`], and
|
||||
/// an outermost box expands at most [`BoxBudget::MAX_NESTED`] boxes inside it. (A box whose
|
||||
/// shapes all show that same box, or chains of boxes each showing the next many times, would
|
||||
/// otherwise grow exponentially.) Outermost boxes aren't capped: their number is the document's
|
||||
/// (or its pages', for headers), so the work stays linear and no box is left blank for budget.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BoxBudget {
|
||||
open: Vec<u32>,
|
||||
nested: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
/// Boxes expanded inside one outermost box.
|
||||
pub const MAX_NESTED: usize = 64;
|
||||
|
||||
/// Start expanding text box story `id`; false if it mustn't be (pair a true with `leave`).
|
||||
pub fn enter(&mut self, id: u32) -> bool {
|
||||
if self.open.contains(&id) || self.open.len() >= MAX_TEXT_BOX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if self.open.is_empty() {
|
||||
self.nested = 0;
|
||||
} else if self.nested >= Self::MAX_NESTED {
|
||||
return false;
|
||||
} else {
|
||||
self.nested += 1;
|
||||
}
|
||||
self.total += 1;
|
||||
self.open.push(id);
|
||||
true
|
||||
}
|
||||
|
||||
/// Done expanding the box last entered.
|
||||
pub fn leave(&mut self) {
|
||||
self.open.pop();
|
||||
}
|
||||
|
||||
/// Whether a text box is being expanded now.
|
||||
pub fn inside(&self) -> bool {
|
||||
!self.open.is_empty()
|
||||
}
|
||||
|
||||
/// Boxes expanded so far.
|
||||
pub fn total(&self) -> usize {
|
||||
self.total
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//! 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,8 +74,6 @@ 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()),
|
||||
@@ -238,7 +236,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(ToString::to_string).unwrap_or_else(|| n.to_string())
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
|
||||
}
|
||||
|
||||
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
|
||||
@@ -326,17 +324,6 @@ 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);
|
||||
|
||||
@@ -64,11 +64,6 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
|
||||
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
|
||||
/// first is 0 for non-empty text.
|
||||
pub fn cluster_starts(text: &str) -> Vec<usize> {
|
||||
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
|
||||
if text.is_ascii() {
|
||||
let b = text.as_bytes();
|
||||
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
|
||||
}
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
@@ -115,22 +110,12 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
|
||||
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
|
||||
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
|
||||
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
|
||||
/// South and South-East Asia.
|
||||
pub fn backspaces_by_char(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x0897..=0x089F
|
||||
| 0x08CA..=0x08E1
|
||||
| 0x08E3..=0x08FF
|
||||
| 0x10EFA..=0x10EFF
|
||||
| 0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
| 0x0E00..=0x0EFF // Thai, Lao
|
||||
| 0x0F00..=0x0FFF // Tibetan
|
||||
| 0x1000..=0x109F // Myanmar
|
||||
@@ -203,22 +188,6 @@ mod tests {
|
||||
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
|
||||
}
|
||||
|
||||
/// The ASCII shortcut gives the grapheme rules' answer.
|
||||
#[test]
|
||||
fn ascii_cluster_starts_match_the_graphemes() {
|
||||
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
|
||||
let all: String = (0u8..128).map(char::from).collect();
|
||||
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
|
||||
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
|
||||
}
|
||||
for a in 0u8..128 {
|
||||
for b in 0u8..128 {
|
||||
let t = format!("{}{}", char::from(a), char::from(b));
|
||||
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
|
||||
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
|
||||
@@ -260,10 +229,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
|
||||
fn backspace_by_character_in_brahmic_scripts_only() {
|
||||
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
|
||||
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
|
||||
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
|
||||
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::Part;
|
||||
use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table};
|
||||
|
||||
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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) 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))
|
||||
}
|
||||
+12
-291
@@ -10,44 +10,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::graphic::PathSeg;
|
||||
use crate::para::{InlineObject, ShapeKind};
|
||||
use crate::para::{GroupChild, 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")]
|
||||
@@ -76,9 +41,6 @@ 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")]
|
||||
@@ -229,16 +191,9 @@ 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 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 };
|
||||
/// 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 {
|
||||
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
|
||||
.iter()
|
||||
.filter_map(|c| match &c.obj {
|
||||
@@ -260,35 +215,9 @@ pub fn reroute_group(group: &mut InlineObject) -> bool {
|
||||
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
|
||||
continue;
|
||||
}
|
||||
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 (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 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;
|
||||
@@ -308,19 +237,7 @@ pub fn reroute_group(group: &mut InlineObject) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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);
|
||||
}
|
||||
}
|
||||
use crate::para::Float;
|
||||
|
||||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
@@ -338,10 +255,6 @@ 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))] {
|
||||
@@ -362,175 +275,6 @@ mod tests {
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
/// One rerouting case: the free end settles and the attached end keeps its site.
|
||||
fn assert_transformed_reroute(kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, free: Option<ConnEnd>, free_start: bool) {
|
||||
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_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] {
|
||||
assert_transformed_reroute(kind, rot, flip_h, flip_v, free, free_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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() };
|
||||
@@ -541,40 +285,17 @@ 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) },
|
||||
];
|
||||
// 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));
|
||||
assert!(reroute_group(&mut kids));
|
||||
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[2];
|
||||
let c = &kids[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 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));
|
||||
}
|
||||
assert!(!reroute_group(&mut kids), "settled");
|
||||
// A connection to a missing id or site keeps that end where it was.
|
||||
let InlineObject::Group { children, .. } = &mut group else { panic!() };
|
||||
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
|
||||
if let InlineObject::Shape { extra, .. } = &mut kids[2].obj {
|
||||
extra.end = Some(ConnEnd { id: 404, site: 0 });
|
||||
extra.start = Some(ConnEnd { id: 7, site: 99 });
|
||||
}
|
||||
assert!(!reroute_group(&mut group));
|
||||
assert!(!reroute_group(&mut kids));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-70
@@ -14,8 +14,6 @@
|
||||
//! 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, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::OBJ;
|
||||
@@ -314,10 +312,10 @@ pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
continue;
|
||||
}
|
||||
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) }),
|
||||
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) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -422,37 +420,24 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
}
|
||||
|
||||
/// Positions of the markers without a partner in a block list and the cells inside it.
|
||||
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let events = control_events(bl);
|
||||
let (_, lone) = pair_events(&events);
|
||||
if !lone.is_empty() {
|
||||
let mut table_counts: HashMap<usize, usize> = HashMap::new();
|
||||
let mut open_nth = Vec::with_capacity(events.len());
|
||||
for e in &events {
|
||||
if let ControlEvent::Open { block, off: None, .. } = e {
|
||||
let count = table_counts.entry(*block).or_insert(0);
|
||||
open_nth.push(*count);
|
||||
*count += 1;
|
||||
} else {
|
||||
open_nth.push(0);
|
||||
}
|
||||
}
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: index among table opens on the same table.
|
||||
let nth = open_nth.get(k).copied().unwrap_or(0);
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: count those before it on the same table.
|
||||
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -654,52 +639,34 @@ impl Document {
|
||||
out.push((r.end.path.clone(), r.end.off));
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
// Markers paired with a table wrap: their partner is never deleted with them.
|
||||
for path in &paths {
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if !inside(path, off)
|
||||
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|
||||
|| in_ranges.contains(&(path, off))
|
||||
{
|
||||
for off in p.object_offsets() {
|
||||
if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
continue;
|
||||
}
|
||||
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
|
||||
if paired.contains(&(path.last(), off)) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
/// (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() };
|
||||
/// 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 };
|
||||
let events = control_events(bl);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
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
|
||||
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()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
|
||||
@@ -707,11 +674,8 @@ 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() };
|
||||
// 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 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));
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
// Only block starts before the caret (or none) and block starts right after it.
|
||||
@@ -724,9 +688,8 @@ 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 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)) {
|
||||
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) {
|
||||
off -= w;
|
||||
}
|
||||
return Pos { off, ..p.clone() };
|
||||
|
||||
@@ -1,866 +0,0 @@
|
||||
use super::block::{Block, Blocks, BoxBudget};
|
||||
use super::compare::{para_block, same_blocks, same_bytes, same_glossary, same_parts};
|
||||
use super::error::{DocError, Result};
|
||||
use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, TextBody, free_id};
|
||||
use super::position::{Path, Pos, StoryRef};
|
||||
use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`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)]
|
||||
pub(crate) 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 {
|
||||
fn default() -> Self {
|
||||
Document::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A blank document: one empty Normal paragraph, Letter, built-in styles.
|
||||
pub fn new() -> Self {
|
||||
Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
core: CoreProps::default(),
|
||||
sources: Vec::new(),
|
||||
custom_props: Vec::new(),
|
||||
footnote_separators: NoteSeparators::default(),
|
||||
endnote_separators: NoteSeparators::default(),
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A document from plain text, one paragraph per line.
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
let mut d = Document::new();
|
||||
d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect();
|
||||
d.ensure_nonempty();
|
||||
d
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks),
|
||||
}
|
||||
}
|
||||
pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Ok(&mut self.body),
|
||||
StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block list that contains the block at `path`.
|
||||
pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> {
|
||||
let mut bl = self.story(s)?;
|
||||
let parent = path.parent();
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
}
|
||||
Some(bl)
|
||||
}
|
||||
|
||||
pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> {
|
||||
let bad = || DocError::BadPath(path.to_string());
|
||||
let parent: Vec<u32> = path.parent().to_vec();
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut bl = self.story_mut(s)?;
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return Err(bad()) };
|
||||
let blk = bl.get_mut(*b as usize).ok_or_else(bad)?;
|
||||
let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) };
|
||||
bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks;
|
||||
}
|
||||
Ok(bl)
|
||||
}
|
||||
|
||||
pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> {
|
||||
self.container(s, path)?.get(path.last()).map(|b| &**b)
|
||||
}
|
||||
|
||||
pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> {
|
||||
self.block(s, path)?.as_para()
|
||||
}
|
||||
pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> {
|
||||
self.para(p.story, &p.path)
|
||||
}
|
||||
|
||||
pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Para(p)) => Ok(p),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> {
|
||||
self.block(s, path)?.as_table()
|
||||
}
|
||||
pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Table(t)) => Ok(t),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths of every paragraph in a story, in document order (tables descended, depth-limited).
|
||||
pub fn para_paths(&self, s: StoryRef) -> Vec<Path> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
walk(bl, &mut Vec::new(), &mut out, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First and last positions of a story.
|
||||
pub fn start_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0));
|
||||
Pos { story: s, path, off: 0 }
|
||||
}
|
||||
pub fn end_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0);
|
||||
Pos { story: s, path, off }
|
||||
}
|
||||
|
||||
/// The position just after the shape that owns text box story `part` (body first, then the
|
||||
/// other parts), for leaving the text box.
|
||||
pub fn text_box_anchor(&self, part: u32) -> Option<Pos> {
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().filter(|k| **k != part).map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The next / previous paragraph path in document order.
|
||||
pub fn next_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
all.get(i + 1).cloned()
|
||||
}
|
||||
pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
i.checked_sub(1).and_then(|j| all.get(j).cloned())
|
||||
}
|
||||
|
||||
/// Clamp a position into the document (valid path, char boundary).
|
||||
pub fn clamp(&self, p: &Pos) -> Pos {
|
||||
if let Some(para) = self.para_at(p) {
|
||||
return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) };
|
||||
}
|
||||
let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body };
|
||||
let all = self.para_paths(story);
|
||||
let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0);
|
||||
Pos { story, path, off }
|
||||
}
|
||||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Plain)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
|
||||
/// Unicode equivalents ([`Paragraph::shown_text`]).
|
||||
pub fn shown_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Shown)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
|
||||
pub fn final_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Final)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0, mode);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Section properties in effect for each top-level body block (index → section number), and
|
||||
/// the list of sections.
|
||||
pub fn sections(&self) -> Vec<(usize, &SectionProps)> {
|
||||
let mut v = Vec::new();
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
if let Block::Para(p) = &**b
|
||||
&& let Some(s) = &p.section
|
||||
{
|
||||
v.push((i, &**s));
|
||||
}
|
||||
}
|
||||
v.push((self.body.len().saturating_sub(1), &self.last_section));
|
||||
v
|
||||
}
|
||||
|
||||
/// The footnote (`endnote` false) or endnote options in effect in section `sect`: its own,
|
||||
/// else the document's, else Word's defaults (footnotes at the page bottom in 1, 2, 3;
|
||||
/// endnotes at the end of the document in i, ii, iii; numbered on through the document).
|
||||
/// Placements that don't apply to the kind fall back to its default.
|
||||
pub fn note_options(&self, sect: &SectionProps, endnote: bool) -> section::NoteOptions {
|
||||
use section::NotePos;
|
||||
let (own, doc, fmt) = if endnote {
|
||||
(§.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format)
|
||||
} else {
|
||||
(§.footnote_pr, &self.settings.footnote_pr, self.settings.footnote_format)
|
||||
};
|
||||
let pos = own.pos.or(doc.pos);
|
||||
let pos = if endnote {
|
||||
pos.filter(|p| matches!(p, NotePos::SectEnd | NotePos::DocEnd)).unwrap_or(NotePos::DocEnd)
|
||||
} else {
|
||||
pos.filter(|p| matches!(p, NotePos::PageBottom | NotePos::BeneathText)).unwrap_or(NotePos::PageBottom)
|
||||
};
|
||||
section::NoteOptions {
|
||||
pos,
|
||||
num_fmt: own.num_fmt.unwrap_or(fmt),
|
||||
num_start: own.num_start.or(doc.num_start).unwrap_or(1).clamp(1, section::MAX_NOTE_START),
|
||||
num_restart: own.num_restart.or(doc.num_restart).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The section that contains top-level body block `block` (index into `sections()`).
|
||||
pub fn section_index_of(&self, block: usize) -> usize {
|
||||
self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mutable section properties for the section containing body block `block`.
|
||||
pub fn section_mut(&mut self, block: usize) -> &mut SectionProps {
|
||||
let ends: Vec<usize> = self.sections().iter().map(|(e, _)| *e).collect();
|
||||
let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1));
|
||||
if idx + 1 < ends.len()
|
||||
&& let Some(e) = ends.get(idx)
|
||||
&& let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut)
|
||||
&& let Some(s) = p.section.as_mut()
|
||||
{
|
||||
return s;
|
||||
}
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Remove the section break on the mark of top-level body paragraph `block`: its section
|
||||
/// joins the following one ([`SectionProps::absorb`]). False when there is no break there.
|
||||
pub fn remove_section_break(&mut self, block: usize) -> bool {
|
||||
if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) {
|
||||
return false;
|
||||
}
|
||||
let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false };
|
||||
let Some(before) = p.section.take() else { return false };
|
||||
self.section_mut(block).absorb(&before);
|
||||
true
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
|
||||
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
|
||||
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
|
||||
self.try_add_part(kind, blocks).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
|
||||
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
|
||||
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
|
||||
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
|
||||
let mut part = Part { kind, blocks, body: TextBody::default() };
|
||||
if part.blocks.is_empty() {
|
||||
part.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
self.parts.insert(id, part);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
/// including generated numbered-list labels).
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.story_word_count(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
|
||||
pub fn word_count_including_notes(&self) -> usize {
|
||||
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
|
||||
}
|
||||
|
||||
/// Word count of one story, including generated numbered-list labels.
|
||||
pub fn story_word_count(&self, s: StoryRef) -> usize {
|
||||
let Some(bl) = self.story(s) else { return 0 };
|
||||
let mut counters = numbering::Counters::default();
|
||||
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
|
||||
}
|
||||
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], ¬es_and_boxes)
|
||||
}
|
||||
|
||||
/// The stories a reader sees, in reading order: the body, then the headers and footers in
|
||||
/// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header,
|
||||
/// a first-page or even-page header whose option is off, a linked text box's own text) and
|
||||
/// comment text aren't included.
|
||||
pub fn shown_stories(&self) -> Vec<StoryRef> {
|
||||
let followers: std::collections::BTreeSet<u32> = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect();
|
||||
let follow = |o: &InlineObject| {
|
||||
let mut v = notes_and_boxes(o);
|
||||
v.retain(|(id, _)| !followers.contains(id));
|
||||
v
|
||||
};
|
||||
let mut out = self.reachable(vec![StoryRef::Body], &follow);
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
let mut root_seen = std::collections::BTreeSet::new();
|
||||
let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default());
|
||||
for (_, sec) in self.sections() {
|
||||
// Unset versions are inherited from the previous section.
|
||||
for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] {
|
||||
set.default = own.default.or(set.default);
|
||||
set.first = own.first.or(set.first);
|
||||
set.even = own.even.or(set.even);
|
||||
let first = set.first.filter(|_| sec.title_page);
|
||||
let even = set.even.filter(|_| self.settings.even_odd_headers);
|
||||
// By the first page each one shows on: page 1, then page 2 (even), then page 3.
|
||||
let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] };
|
||||
for id in order.into_iter().flatten() {
|
||||
if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) {
|
||||
roots.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.reachable(roots, &follow) {
|
||||
if seen.insert(st) {
|
||||
out.push(st);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
|
||||
/// ones too. Undo snapshots and the clipboard keep their own copies. Free for a document
|
||||
/// without text boxes; otherwise one pass over its paragraphs. Returns how many went.
|
||||
pub fn prune_text_boxes(&mut self) -> usize {
|
||||
if !self.parts.values().any(|p| p.kind == PartKind::TextBox) {
|
||||
return 0;
|
||||
}
|
||||
let roots = std::iter::once(StoryRef::Body)
|
||||
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
|
||||
.collect();
|
||||
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
|
||||
let before = self.parts.len();
|
||||
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
|
||||
let gone = before - self.parts.len();
|
||||
if gone > 0 {
|
||||
// A link to a box that went ends the chain there (its id may be reused).
|
||||
let ids: std::collections::BTreeSet<u32> = self.parts.keys().copied().collect();
|
||||
for p in self.parts.values_mut() {
|
||||
if p.body.next.is_some_and(|n| !ids.contains(&n)) {
|
||||
p.body.next = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
gone
|
||||
}
|
||||
|
||||
/// The chains of linked text boxes (Create Link), each in order from the box holding the
|
||||
/// text: every chain has two or more boxes. A link to a part that isn't a text box, a box
|
||||
/// already linked from another, or links running in a circle are ignored (those boxes show
|
||||
/// their own text); a chain stops after [`MAX_LINKED_BOXES`].
|
||||
pub fn text_box_chains(&self) -> Vec<Vec<u32>> {
|
||||
let is_box = |id: u32| self.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox);
|
||||
// Who links to whom: the first box (by id) to link to a box wins.
|
||||
let mut prev: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for (id, p) in &self.parts {
|
||||
if p.kind != PartKind::TextBox {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = p.body.next.filter(|n| *n != *id && is_box(*n)) {
|
||||
prev.entry(n).or_insert(*id);
|
||||
}
|
||||
}
|
||||
let next = |id: u32| self.parts.get(&id).and_then(|p| p.body.next).filter(|n| prev.get(n) == Some(&id));
|
||||
let mut out = Vec::new();
|
||||
for (id, p) in &self.parts {
|
||||
if p.kind != PartKind::TextBox || prev.contains_key(id) || next(*id).is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut chain = vec![*id];
|
||||
let mut at = *id;
|
||||
while let Some(n) = next(at) {
|
||||
if chain.len() >= MAX_LINKED_BOXES || chain.contains(&n) {
|
||||
break;
|
||||
}
|
||||
chain.push(n);
|
||||
at = n;
|
||||
}
|
||||
out.push(chain);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The chain (see [`Document::text_box_chains`]) text box `part` belongs to, if linked.
|
||||
pub fn text_box_chain(&self, part: u32) -> Option<Vec<u32>> {
|
||||
self.text_box_chains().into_iter().find(|c| c.contains(&part))
|
||||
}
|
||||
|
||||
/// The size (width, height) of the shapes showing text boxes `ids`, in any story (a group
|
||||
/// member's as the group stretches it).
|
||||
pub fn text_box_sizes(&self, ids: &std::collections::BTreeSet<u32>) -> BTreeMap<u32, (f32, f32)> {
|
||||
let mut out = BTreeMap::new();
|
||||
let stories = std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for o in &p.objects {
|
||||
match o {
|
||||
InlineObject::Shape { story: Some(id), w, h, .. } if ids.contains(id) => {
|
||||
out.entry(*id).or_insert((*w, *h));
|
||||
}
|
||||
InlineObject::Group { w, h, .. } => {
|
||||
for (r, c) in o.group_rects(0.0, 0.0, *w, *h) {
|
||||
if let Some(id) = c.text_box().filter(|id| ids.contains(id)) {
|
||||
out.entry(id).or_insert((r[2], r[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every inline object in reading order: the body's, with each text box's objects where the
|
||||
/// box is (nested boxes too, within a [`BoxBudget`]).
|
||||
pub fn objects_in_reading_order(&self, f: &mut dyn FnMut(&InlineObject)) {
|
||||
self.walk_objects(&self.body, &mut BoxBudget::default(), &mut |p, k| {
|
||||
if let Some(o) = p.objects.get(k) {
|
||||
f(o);
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Like [`Document::objects_in_reading_order`], with each object's paragraph and index;
|
||||
/// `f` returns whether to go into the object's text box (false: the box is left out).
|
||||
pub fn objects_in_reading_order_at(&self, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
|
||||
self.walk_objects(&self.body, &mut BoxBudget::default(), f);
|
||||
}
|
||||
|
||||
/// Like [`Document::objects_in_reading_order_at`], also giving the top-level body block each
|
||||
/// object is in (for objects in text boxes, the block of the box's anchor).
|
||||
pub fn objects_in_reading_order_by_block(&self, f: &mut dyn FnMut(usize, &Paragraph, usize) -> bool) {
|
||||
let mut budget = BoxBudget::default();
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
self.walk_objects(std::slice::from_ref(b), &mut budget, &mut |p, k| f(i, p, k));
|
||||
}
|
||||
}
|
||||
|
||||
/// The footnotes and endnotes referenced inside text box story `part` (nested boxes too), in
|
||||
/// reading order.
|
||||
pub fn notes_in_text_box(&self, part: u32) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
let mut budget = BoxBudget::default();
|
||||
if let Some(p) = self.parts.get(&part).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(part)
|
||||
{
|
||||
self.walk_objects(&p.blocks, &mut budget, &mut |p, k| {
|
||||
if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(k) {
|
||||
out.push(*id);
|
||||
}
|
||||
true
|
||||
});
|
||||
budget.leave();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_objects(&self, blocks: &[Arc<Block>], budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
if !f(p, k) {
|
||||
continue;
|
||||
}
|
||||
for id in o.text_boxes() {
|
||||
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
|
||||
/// part kinds that count), transitively, in the order found.
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
let mut out = roots;
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut i = 0;
|
||||
while let Some(s) = out.get(i).copied() {
|
||||
i += 1;
|
||||
let mut found = Vec::new();
|
||||
for b in self.story(s).into_iter().flatten() {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
found.extend(p.objects.iter().flat_map(follow));
|
||||
});
|
||||
}
|
||||
for (id, kinds) in found {
|
||||
if self.parts.get(&id).is_some_and(|p| kinds.contains(&p.kind)) && seen.insert(StoryRef::Part(id)) {
|
||||
out.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A drawing id no shape uses yet (for gluing a connector to a shape that has none).
|
||||
pub fn next_shape_id(&self) -> u32 {
|
||||
let mut max = 0u32;
|
||||
let mut see = |o: &InlineObject| match o {
|
||||
InlineObject::Shape { extra, .. } => max = max.max(extra.id),
|
||||
InlineObject::Group { children, .. } => {
|
||||
for c in children {
|
||||
if let InlineObject::Shape { extra, .. } = &c.obj {
|
||||
max = max.max(extra.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
for blocks in std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)) {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| p.objects.iter().for_each(&mut see));
|
||||
}
|
||||
}
|
||||
max.saturating_add(1).max(1)
|
||||
}
|
||||
|
||||
/// Change every inline object in every story (paragraphs inside tables too), copying only
|
||||
/// the blocks whose paragraphs `wants` says have objects to change.
|
||||
pub fn objects_mut(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
let mut any = false;
|
||||
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
|
||||
if any {
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
|
||||
if p.objects.iter().any(wants) {
|
||||
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
|
||||
p.touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
|
||||
/// JSON, whose run lengths may overflow, fall short or split a character.
|
||||
pub fn normalize_runs(&mut self) {
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of paragraphs in the body (all depths).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
self.para_paths(StoryRef::Body).len()
|
||||
}
|
||||
|
||||
/// Add a media item; returns its key (deduplicated by content).
|
||||
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
|
||||
let ix = &mut self.media_index;
|
||||
if ix.len != self.media.len() {
|
||||
ix.rebuild(&self.media);
|
||||
}
|
||||
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 = i.saturating_add(1);
|
||||
key = format!("image{i}.{ext}");
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// The value of a custom property (names compare case-insensitively, as in Word).
|
||||
pub fn custom_prop(&self, name: &str) -> Option<&str> {
|
||||
self.custom_props.iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.value.as_str())
|
||||
}
|
||||
|
||||
/// Set a custom text property, replacing one of the same name in place.
|
||||
pub fn set_custom_prop(&mut self, name: &str, value: &str) {
|
||||
match self.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name)) {
|
||||
Some(p) => {
|
||||
p.kind = "lpwstr".into();
|
||||
p.value = value.to_string();
|
||||
}
|
||||
None => {
|
||||
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a custom property. Returns whether it existed.
|
||||
pub fn remove_custom_prop(&mut self, name: &str) -> bool {
|
||||
let n = self.custom_props.len();
|
||||
self.custom_props.retain(|p| !p.name.eq_ignore_ascii_case(name));
|
||||
self.custom_props.len() != n
|
||||
}
|
||||
|
||||
/// Ranges that stay editable while the document is protected (Restrict Editing exceptions),
|
||||
/// in document order across every story: the [`InlineObject::PermStart`]'s id, group and
|
||||
/// editor, its start (just after the marker) and its end (at the matching
|
||||
/// [`InlineObject::PermEnd`], or the end of the story when it has none).
|
||||
pub fn perm_ranges(&self) -> Vec<PermRange> {
|
||||
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 {
|
||||
// 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, 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.entry(*id).or_default().push(out.len());
|
||||
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A range left open runs to the end of its story.
|
||||
let end = self.end_of(story);
|
||||
for i in open.into_values().flatten() {
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = end.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bookmark names in the body, in order.
|
||||
pub fn bookmarks(&self) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for path in self.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = self.para(StoryRef::Body, &path) {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
+558
-251
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::{InlineObject, Run};
|
||||
use crate::para::InlineObject;
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
|
||||
use crate::{Table, table::MAX_TABLE_DEPTH};
|
||||
@@ -188,111 +188,6 @@ 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
|
||||
@@ -434,16 +329,10 @@ impl Document {
|
||||
at.extend(offs.get(k).copied());
|
||||
}
|
||||
}
|
||||
// Reference offsets and run boundaries are ordered; do not restart at the first
|
||||
// reference for every run, including runs after the last reference.
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let end = start.saturating_add(r.len);
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
if at.iter().any(|&o| o >= start && o < end) {
|
||||
swap(&mut r.props.style);
|
||||
}
|
||||
start = end;
|
||||
@@ -585,13 +474,7 @@ impl Document {
|
||||
}
|
||||
// Content control markers whose partner is outside the range stay (the control is
|
||||
// emptied, not broken).
|
||||
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 = self.kept_control_markers(&a, &b);
|
||||
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))?;
|
||||
@@ -602,21 +485,13 @@ 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();
|
||||
// `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()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -798,63 +673,6 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
|
||||
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
|
||||
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
|
||||
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
|
||||
let t = self.table(story, tp)?.slice(rows, cols);
|
||||
if t.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.fragment_of(vec![Block::Table(t)]))
|
||||
}
|
||||
|
||||
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
|
||||
/// outermost one covered whole (a table nested in a cell when the range covers just it).
|
||||
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
|
||||
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Option<Vec<Path>> = None;
|
||||
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
|
||||
// every three steps (block, row, cell) deeper.
|
||||
let mut n = 1usize;
|
||||
while n.saturating_add(3) <= a.path.0.len() {
|
||||
let pre = a.path.0.get(..n)?;
|
||||
if !b.path.0.starts_with(pre) {
|
||||
return None;
|
||||
}
|
||||
let tp = Path(pre.to_vec());
|
||||
if self.table(a.story, &tp).is_some() {
|
||||
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
if let Some(first) = inside.next() {
|
||||
let last = inside.next_back().unwrap_or(first);
|
||||
let end = self.para(a.story, last).map_or(0, Paragraph::len);
|
||||
if *first == a.path && *last == b.path && b.off >= end {
|
||||
return Some(tp);
|
||||
}
|
||||
}
|
||||
}
|
||||
n = n.saturating_add(3);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
@@ -909,13 +727,6 @@ impl Document {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
|
||||
if let Some(tp) = self.whole_table(&a, &b)
|
||||
&& let Some(blk) = self.block(a.story, &tp)
|
||||
{
|
||||
out.push(blk.clone());
|
||||
return out;
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
@@ -933,7 +744,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 BoxBudget) {
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) {
|
||||
let mut any = false;
|
||||
for b in blocks.iter() {
|
||||
each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some());
|
||||
@@ -959,53 +770,18 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||||
// 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();
|
||||
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
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()?;
|
||||
*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()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
@@ -1027,15 +803,8 @@ 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 = BoxBudget::new();
|
||||
let mut budget = MAX_PASTED_BOXES;
|
||||
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()
|
||||
@@ -1110,4 +879,542 @@ impl Document {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{StoryRef, Table};
|
||||
|
||||
fn doc(lines: &[&str]) -> Document {
|
||||
Document::from_text(&lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Paths between two positions are those of all the story's paragraphs between them.
|
||||
#[test]
|
||||
fn paths_between_matches_every_path_filtered() {
|
||||
let mut d = doc(&["a", "b", "c", "d", "e"]);
|
||||
let mut inner = Table::new(2, 2, 100.0);
|
||||
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
|
||||
let mut t = Table::new(3, 2, 200.0);
|
||||
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
|
||||
let all = d.para_paths(StoryRef::Body);
|
||||
for a in &all {
|
||||
for b in &all {
|
||||
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
|
||||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
|
||||
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_join() {
|
||||
let mut d = doc(&["Hello world"]);
|
||||
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
|
||||
assert_eq!(p, Pos::body(1, 0));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
|
||||
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 5));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_with_bad_end_leaves_document_unchanged() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
// Missing paragraph as the second end.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset past the end of the second paragraph.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset inside a multi-byte character.
|
||||
let mut d = doc(&["abc", "\u{e9}x"]);
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
|
||||
// Range entering a table with a bad offset at its start: the cell text stays.
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
|
||||
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_multi() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "oree");
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
/// #2127: a range over two section breaks joins three sections into the last; the headers
|
||||
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
|
||||
/// named it, as they resolved before the delete.
|
||||
#[test]
|
||||
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
|
||||
let mut d = doc(&["A", "B", "C"]);
|
||||
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
|
||||
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
|
||||
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
|
||||
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
|
||||
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(s));
|
||||
}
|
||||
}
|
||||
assert_eq!(d.sections().len(), 4);
|
||||
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "AC");
|
||||
let sections = d.sections();
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
|
||||
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.props_of_char(0).bold, None);
|
||||
assert_eq!(p0.props_of_char(1).bold, Some(true));
|
||||
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
assert_eq!(p1.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p1.props_of_char(2).bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_round_trip() {
|
||||
let mut d = doc(&["alpha", "beta", "gamma"]);
|
||||
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
|
||||
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
|
||||
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
|
||||
assert_eq!(end, Pos::body(4, 3));
|
||||
let one = Fragment::from_text("XY");
|
||||
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
|
||||
assert_eq!(e, Pos::body(0, 2));
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn box_shape(story: Option<u32>) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: crate::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story ids of the boxes in a body paragraph, in order.
|
||||
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
|
||||
let p = d.para(StoryRef::Body, path).unwrap();
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_text_box_gets_its_own_story() {
|
||||
let mut d = doc(&["ab", "cd"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
|
||||
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
|
||||
// Edit the original after copying: the paste keeps the copied text.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
|
||||
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
|
||||
let pasted = boxes_in(&d, &Path::top(1));
|
||||
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
|
||||
assert_ne!(new, id);
|
||||
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
|
||||
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
// Editing the copy leaves the original alone; a second paste is a third story.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
|
||||
let again = boxes_in(&d, &Path::top(1));
|
||||
assert_eq!(again.len(), 2);
|
||||
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
|
||||
// Multi-paragraph pastes too.
|
||||
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
|
||||
assert_eq!(d.parts.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_pasted_into_another_document_brings_its_text() {
|
||||
let mut src = doc(&["x"]);
|
||||
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
|
||||
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
// The target already has a header with the same part id.
|
||||
let mut dst = doc(&["target"]);
|
||||
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
|
||||
assert_eq!(hdr, id);
|
||||
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
|
||||
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
|
||||
assert_ne!(new, hdr);
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
|
||||
// The whole body of a document, as Insert › Text from File uses it.
|
||||
let mut dst2 = doc(&["t"]);
|
||||
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
|
||||
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
|
||||
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_copied_table_gets_its_own_story() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
|
||||
let id = text_box(&mut d, &cell, "In a cell");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
|
||||
assert!(f.parts.contains_key(&id));
|
||||
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
let ids: Vec<u32> = d
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.filter_map(|p| d.para(StoryRef::Body, p))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2, "{ids:?}");
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_without_parts_never_shows_an_unrelated_story() {
|
||||
let mut d = doc(&["x"]);
|
||||
let hdr = d.add_part(PartKind::Header, Vec::new());
|
||||
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
assert_eq!(got[0], None, "missing story");
|
||||
let Some(copy) = got[1] else { panic!("{got:?}") };
|
||||
assert_ne!(copy, own);
|
||||
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
|
||||
assert_eq!(got[2], None, "a header isn't a text box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_nested_text_box_paste_is_bounded() {
|
||||
let mut d = doc(&["x"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
|
||||
// The box contains a box showing itself.
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_in_paths() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
|
||||
let paths = d.para_paths(StoryRef::Body);
|
||||
assert_eq!(paths.len(), 6);
|
||||
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
|
||||
assert_eq!(e.off, 1);
|
||||
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
|
||||
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
|
||||
// Delete from before the table into a cell: text cleared, table kept (partially covered).
|
||||
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
|
||||
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Para(Paragraph::with_text(text, CharProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
|
||||
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\n");
|
||||
// A piece that already ends with its paragraph mark gets no second one.
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\n");
|
||||
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_keep_their_own_text_boxes() {
|
||||
// Two documents give their first text box the same id.
|
||||
let mut one = doc(&["x"]);
|
||||
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
|
||||
let mut two = doc(&["y"]);
|
||||
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
|
||||
assert_eq!(id1, id2);
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
let mut dst = doc(&[""]);
|
||||
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
|
||||
let texts: Vec<String> =
|
||||
[0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
|
||||
assert_eq!(texts, ["One", "Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
|
||||
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
|
||||
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
|
||||
let c = d.clamp(&Pos::body(9, 9));
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
|
||||
// delete_range over whole tables (#1844), in the body, a header and a footnote.
|
||||
|
||||
/// A document whose story (the body, or a header or footnote part) holds `lines`.
|
||||
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
|
||||
match kind {
|
||||
None => (doc(lines), StoryRef::Body),
|
||||
Some(k) => {
|
||||
let mut d = doc(&["body"]);
|
||||
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
|
||||
let id = d.add_part(k, blocks);
|
||||
(d, StoryRef::Part(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
|
||||
|
||||
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
|
||||
Pos { story, path: Path(path.to_vec()), off }
|
||||
}
|
||||
|
||||
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
|
||||
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
|
||||
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
|
||||
for (k, t) in texts.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
|
||||
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The story's top-level blocks: a paragraph's text, or `[table]`.
|
||||
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
|
||||
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
|
||||
let mut p = table.to_vec();
|
||||
p.extend([r, c, 0]);
|
||||
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
|
||||
for kind in STORIES {
|
||||
for (r, c) in [(0, 1), (1, 0)] {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
|
||||
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
|
||||
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
|
||||
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
|
||||
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["p"]);
|
||||
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
|
||||
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
|
||||
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
|
||||
assert_eq!(cell, ["pq"], "{kind:?}");
|
||||
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
|
||||
for kind in STORIES {
|
||||
for trailing in [true, false] {
|
||||
let (mut d, st) = story_doc(kind, &[""]);
|
||||
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
|
||||
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
|
||||
if !trailing {
|
||||
// A story that ends in a table (a hostile file).
|
||||
d.story_mut(st).unwrap().pop();
|
||||
}
|
||||
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
|
||||
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content controls pair their markers within one block list (a story, or a cell), so a
|
||||
/// marker in a table never has its partner outside it: a control inside a table removed whole
|
||||
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
|
||||
#[test]
|
||||
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
use crate::control::ContentControl;
|
||||
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["c"]);
|
||||
// One control around "efo" in "before", one around the cell's text.
|
||||
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(st).len(), 2, "setup");
|
||||
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
|
||||
// From inside the first control to the end of the table.
|
||||
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
|
||||
let ranges = d.control_ranges(st);
|
||||
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
|
||||
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
|
||||
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
|
||||
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
t = outer;
|
||||
}
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{StoryRef, Table};
|
||||
|
||||
fn doc(lines: &[&str]) -> Document {
|
||||
Document::from_text(&lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Paths between two positions are those of all the story's paragraphs between them.
|
||||
#[test]
|
||||
fn paths_between_matches_every_path_filtered() {
|
||||
let mut d = doc(&["a", "b", "c", "d", "e"]);
|
||||
let mut inner = Table::new(2, 2, 100.0);
|
||||
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
|
||||
let mut t = Table::new(3, 2, 200.0);
|
||||
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
|
||||
let all = d.para_paths(StoryRef::Body);
|
||||
for a in &all {
|
||||
for b in &all {
|
||||
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
|
||||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
|
||||
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_join() {
|
||||
let mut d = doc(&["Hello world"]);
|
||||
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
|
||||
assert_eq!(p, Pos::body(1, 0));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
|
||||
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 5));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_with_bad_end_leaves_document_unchanged() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
// Missing paragraph as the second end.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset past the end of the second paragraph.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset inside a multi-byte character.
|
||||
let mut d = doc(&["abc", "\u{e9}x"]);
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
|
||||
// Range entering a table with a bad offset at its start: the cell text stays.
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
|
||||
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_multi() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "oree");
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
/// #2127: a range over two section breaks joins three sections into the last; the headers
|
||||
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
|
||||
/// named it, as they resolved before the delete.
|
||||
#[test]
|
||||
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
|
||||
let mut d = doc(&["A", "B", "C"]);
|
||||
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
|
||||
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
|
||||
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
|
||||
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
|
||||
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(s));
|
||||
}
|
||||
}
|
||||
assert_eq!(d.sections().len(), 4);
|
||||
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "AC");
|
||||
let sections = d.sections();
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
|
||||
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.props_of_char(0).bold, None);
|
||||
assert_eq!(p0.props_of_char(1).bold, Some(true));
|
||||
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
assert_eq!(p1.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p1.props_of_char(2).bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_round_trip() {
|
||||
let mut d = doc(&["alpha", "beta", "gamma"]);
|
||||
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
|
||||
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
|
||||
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
|
||||
assert_eq!(end, Pos::body(4, 3));
|
||||
let one = Fragment::from_text("XY");
|
||||
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
|
||||
assert_eq!(e, Pos::body(0, 2));
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn box_shape(story: Option<u32>) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: crate::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story ids of the boxes in a body paragraph, in order.
|
||||
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
|
||||
let p = d.para(StoryRef::Body, path).unwrap();
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_text_box_gets_its_own_story() {
|
||||
let mut d = doc(&["ab", "cd"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
|
||||
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
|
||||
// Edit the original after copying: the paste keeps the copied text.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
|
||||
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
|
||||
let pasted = boxes_in(&d, &Path::top(1));
|
||||
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
|
||||
assert_ne!(new, id);
|
||||
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
|
||||
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
// Editing the copy leaves the original alone; a second paste is a third story.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
|
||||
let again = boxes_in(&d, &Path::top(1));
|
||||
assert_eq!(again.len(), 2);
|
||||
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
|
||||
// Multi-paragraph pastes too.
|
||||
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
|
||||
assert_eq!(d.parts.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_pasted_into_another_document_brings_its_text() {
|
||||
let mut src = doc(&["x"]);
|
||||
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
|
||||
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
// The target already has a header with the same part id.
|
||||
let mut dst = doc(&["target"]);
|
||||
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
|
||||
assert_eq!(hdr, id);
|
||||
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
|
||||
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
|
||||
assert_ne!(new, hdr);
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
|
||||
// The whole body of a document, as Insert › Text from File uses it.
|
||||
let mut dst2 = doc(&["t"]);
|
||||
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
|
||||
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
|
||||
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_copied_table_gets_its_own_story() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
|
||||
let id = text_box(&mut d, &cell, "In a cell");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
|
||||
assert!(f.parts.contains_key(&id));
|
||||
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
let ids: Vec<u32> = d
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.filter_map(|p| d.para(StoryRef::Body, p))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2, "{ids:?}");
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_without_parts_never_shows_an_unrelated_story() {
|
||||
let mut d = doc(&["x"]);
|
||||
let hdr = d.add_part(PartKind::Header, Vec::new());
|
||||
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
assert_eq!(got[0], None, "missing story");
|
||||
let Some(copy) = got[1] else { panic!("{got:?}") };
|
||||
assert_ne!(copy, own);
|
||||
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
|
||||
assert_eq!(got[2], None, "a header isn't a text box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_nested_text_box_paste_is_bounded() {
|
||||
let mut d = doc(&["x"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
|
||||
// The box contains a box showing itself.
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
// 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]
|
||||
fn tables_in_paths() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
|
||||
let paths = d.para_paths(StoryRef::Body);
|
||||
assert_eq!(paths.len(), 6);
|
||||
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
|
||||
assert_eq!(e.off, 1);
|
||||
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
|
||||
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
|
||||
// Delete from before the table into a cell: text cleared, table kept (partially covered).
|
||||
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
|
||||
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Para(Paragraph::with_text(text, CharProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
|
||||
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\n");
|
||||
// A piece that already ends with its paragraph mark gets no second one.
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\n");
|
||||
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_keep_their_own_text_boxes() {
|
||||
// Two documents give their first text box the same id.
|
||||
let mut one = doc(&["x"]);
|
||||
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
|
||||
let mut two = doc(&["y"]);
|
||||
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
|
||||
assert_eq!(id1, id2);
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
let mut dst = doc(&[""]);
|
||||
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
|
||||
let texts: Vec<String> = [0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
|
||||
assert_eq!(texts, ["One", "Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
|
||||
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
|
||||
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
|
||||
let c = d.clamp(&Pos::body(9, 9));
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
|
||||
// delete_range over whole tables (#1844), in the body, a header and a footnote.
|
||||
|
||||
/// A document whose story (the body, or a header or footnote part) holds `lines`.
|
||||
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
|
||||
match kind {
|
||||
None => (doc(lines), StoryRef::Body),
|
||||
Some(k) => {
|
||||
let mut d = doc(&["body"]);
|
||||
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
|
||||
let id = d.add_part(k, blocks);
|
||||
(d, StoryRef::Part(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
|
||||
|
||||
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
|
||||
Pos { story, path: Path(path.to_vec()), off }
|
||||
}
|
||||
|
||||
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
|
||||
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
|
||||
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
|
||||
for (k, t) in texts.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
|
||||
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The story's top-level blocks: a paragraph's text, or `[table]`.
|
||||
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
|
||||
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
|
||||
let mut p = table.to_vec();
|
||||
p.extend([r, c, 0]);
|
||||
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
|
||||
for kind in STORIES {
|
||||
for (r, c) in [(0, 1), (1, 0)] {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
|
||||
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
|
||||
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
|
||||
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
|
||||
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["p"]);
|
||||
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
|
||||
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
|
||||
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
|
||||
assert_eq!(cell, ["pq"], "{kind:?}");
|
||||
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
|
||||
for kind in STORIES {
|
||||
for trailing in [true, false] {
|
||||
let (mut d, st) = story_doc(kind, &[""]);
|
||||
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
|
||||
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
|
||||
if !trailing {
|
||||
// A story that ends in a table (a hostile file).
|
||||
d.story_mut(st).unwrap().pop();
|
||||
}
|
||||
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
|
||||
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content controls pair their markers within one block list (a story, or a cell), so a
|
||||
/// marker in a table never has its partner outside it: a control inside a table removed whole
|
||||
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
|
||||
#[test]
|
||||
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
use crate::control::ContentControl;
|
||||
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["c"]);
|
||||
// One control around "efo" in "before", one around the cell's text.
|
||||
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(st).len(), 2, "setup");
|
||||
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
|
||||
// From inside the first control to the end of the table.
|
||||
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
|
||||
let ranges = d.control_ranges(st);
|
||||
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
|
||||
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
|
||||
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
|
||||
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
t = outer;
|
||||
}
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
|
||||
}
|
||||
// A switch: its letters, then an optional (signed) number.
|
||||
Some(_) => {
|
||||
while it.next_if(char::is_ascii_alphabetic).is_some() {}
|
||||
while it.next_if(|n| n.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() {}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
use super::position::StoryRef;
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("offset {0} is not a character boundary in the paragraph")]
|
||||
BadOffset(usize),
|
||||
#[error("no paragraph at {0}")]
|
||||
BadPath(String),
|
||||
#[error("no story {0:?}")]
|
||||
BadStory(StoryRef),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
@@ -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, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::FieldStart { instr, locked, .. } => {
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
InlineObject::FieldEnd => match open.pop() {
|
||||
Some(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,10 +99,7 @@ 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;
|
||||
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
|
||||
if at != off || !matches!(o, InlineObject::FieldEnd) {
|
||||
break;
|
||||
}
|
||||
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
|
||||
@@ -1,712 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
+1566
-30
File diff suppressed because it is too large
Load Diff
@@ -102,9 +102,6 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -154,15 +151,6 @@ 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,24 +41,6 @@ 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`).
|
||||
@@ -1113,20 +1095,4 @@ 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(_)]));
|
||||
}
|
||||
}
|
||||
|
||||
+57
-62
@@ -1061,8 +1061,63 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
*kind = ScriptKind::SubSup;
|
||||
here(if action == "script.addSup" { 2 } else { 1 })
|
||||
}
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here),
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner),
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => {
|
||||
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
|
||||
// Row and column of the caret's cell.
|
||||
let mut k = 0;
|
||||
let mut rc = (0, 0);
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
if c < k + row.len() {
|
||||
rc = (r, c - k);
|
||||
break;
|
||||
}
|
||||
k += row.len();
|
||||
}
|
||||
let (r, col) = rc;
|
||||
let (mut tr, mut tc) = (r, col);
|
||||
match a {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "matrix.rowAbove" { r } else { r + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]);
|
||||
tr = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if ncols < 64 => {
|
||||
let at = if a == "matrix.colBefore" { col } else { col + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
tc = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(r.min(rows.len() - 1));
|
||||
tr = r.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if ncols > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if col < row.len() {
|
||||
row.remove(col);
|
||||
}
|
||||
}
|
||||
tc = col.min(ncols - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::<usize>() + tc;
|
||||
here(flat)
|
||||
}
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let k = c.min(rows.len() - 1);
|
||||
rows.remove(k);
|
||||
here(k.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
},
|
||||
(MNode::GroupChr { top, chr, .. }, "group.flip") => {
|
||||
*top = !*top;
|
||||
*chr = match *chr {
|
||||
@@ -1086,66 +1141,6 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
Some(clamp(root, &out))
|
||||
}
|
||||
|
||||
fn apply_matrix_action(rows: &mut Vec<Vec<Arg>>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos {
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let (mut row_index, mut column) = (0, 0);
|
||||
let mut offset = 0;
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if caret < offset + row.len() {
|
||||
row_index = index;
|
||||
column = caret - offset;
|
||||
break;
|
||||
}
|
||||
offset += row.len();
|
||||
}
|
||||
let (mut target_row, mut target_column) = (row_index, column);
|
||||
match action {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); columns]);
|
||||
target_row = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if columns < 64 => {
|
||||
let at = if action == "matrix.colBefore" { column } else { column + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
target_column = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(row_index.min(rows.len() - 1));
|
||||
target_row = row_index.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if columns > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if column < row.len() {
|
||||
row.remove(column);
|
||||
}
|
||||
}
|
||||
target_column = column.min(columns - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat = rows.iter().take(target_row).map(Vec::len).sum::<usize>() + target_column;
|
||||
here(flat)
|
||||
}
|
||||
|
||||
fn apply_equation_array_action(rows: &mut Vec<Arg>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos {
|
||||
match action {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let index = caret.min(rows.len() - 1);
|
||||
rows.remove(index);
|
||||
here(index.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the limit placement of n-ary operators (`None`: Word's default by operator).
|
||||
pub fn set_limits(nodes: &mut Arg, loc: Option<LimLoc>) {
|
||||
for n in nodes.iter_mut() {
|
||||
|
||||
+211
-298
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
match c {
|
||||
'\\' => {
|
||||
let start = i;
|
||||
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
|
||||
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
i += 1;
|
||||
}
|
||||
if i == start {
|
||||
@@ -324,21 +324,6 @@ 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();
|
||||
@@ -816,31 +801,6 @@ 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(),
|
||||
@@ -863,250 +823,231 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn node(node: &MNode, output: &mut String, depth: usize) {
|
||||
match node {
|
||||
MNode::Run(run) => run_node(run, output),
|
||||
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
|
||||
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
|
||||
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
|
||||
MNode::Nary { .. } => nary_node(node, output, depth),
|
||||
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
|
||||
fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
let (open, close) = if r.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (r.sty, r.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => {
|
||||
if FUNCTION_NAMES.contains(&r.text.as_str()) {
|
||||
s.push('\\');
|
||||
s.push_str(&r.text);
|
||||
s.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
s.push_str(open);
|
||||
if r.nor {
|
||||
text_chars(&r.text, s);
|
||||
} else {
|
||||
chars(&r.text, s);
|
||||
}
|
||||
s.push_str(close);
|
||||
}
|
||||
MNode::Frac { kind, num, den } => match kind {
|
||||
FracKind::NoBar => {
|
||||
s.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(num, s, depth);
|
||||
s.push('/');
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
s.push_str("\\frac");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
},
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push_str("{}_");
|
||||
braced(sub, s, depth);
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
braced(base, s, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
s.push_str("\\sqrt");
|
||||
if !deg_hide && !deg.is_empty() {
|
||||
s.push('[');
|
||||
arg(deg, s, depth);
|
||||
s.push(']');
|
||||
}
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
|
||||
match name_of(*chr) {
|
||||
Some(n) => {
|
||||
s.push('\\');
|
||||
s.push_str(n);
|
||||
}
|
||||
None => s.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
s.push(' ');
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Delim { beg, end, sep, elems, .. } => {
|
||||
s.push_str("\\left");
|
||||
s.push_str(&delim_tex(*beg));
|
||||
let mid = match sep {
|
||||
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
|
||||
None => "\\middle|".to_string(),
|
||||
};
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(&mid);
|
||||
}
|
||||
arg(e, s, depth);
|
||||
}
|
||||
s.push_str("\\right");
|
||||
s.push_str(&delim_tex(*end));
|
||||
}
|
||||
MNode::Func { name, e } => {
|
||||
arg(name, output, depth);
|
||||
braced(e, output, depth);
|
||||
arg(name, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
// `\lim_{…}` keeps its name; other bases stack.
|
||||
let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(e, s, depth);
|
||||
s.push('_');
|
||||
braced(lim, s, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
} else {
|
||||
s.push_str(if *upper { "\\overset" } else { "\\underset" });
|
||||
braced(lim, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
let cmd = match chr {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
s.push('\\');
|
||||
s.push_str(cmd);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
|
||||
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
|
||||
MNode::Bar { top, e } => {
|
||||
output.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, output, depth);
|
||||
s.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::BorderBox { e, .. } => {
|
||||
output.push_str("\\boxed");
|
||||
braced(e, output, depth);
|
||||
s.push_str("\\boxed");
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Boxed { e } => braced(e, output, depth),
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, output, depth);
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
|
||||
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
|
||||
MNode::Phant { e, .. } => {
|
||||
output.push_str("\\phantom");
|
||||
braced(e, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_node(run: &MRun, output: &mut String) {
|
||||
let (open, close) = if run.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (run.sty, run.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
|
||||
if FUNCTION_NAMES.contains(&run.text.as_str()) {
|
||||
output.push('\\');
|
||||
output.push_str(&run.text);
|
||||
output.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
MNode::EqArr { rows } => {
|
||||
if rows.len() == 1
|
||||
&& let Some(r) = rows.first()
|
||||
&& crate::math::has_number_mark(r)
|
||||
{
|
||||
arg(r, s, depth);
|
||||
return;
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
output.push_str(open);
|
||||
if run.nor {
|
||||
text_chars(&run.text, output)
|
||||
} else {
|
||||
chars(&run.text, output)
|
||||
}
|
||||
output.push_str(close);
|
||||
}
|
||||
|
||||
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
|
||||
match kind {
|
||||
FracKind::NoBar => {
|
||||
output.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(numerator, output, depth);
|
||||
output.push('/');
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
output.push_str("\\frac");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
|
||||
if kind == ScriptKind::Pre {
|
||||
output.push_str("{}_");
|
||||
braced(sub, output, depth);
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
braced(base, output, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, output, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
output.push_str("\\sqrt");
|
||||
if !degree_hidden && !degree.is_empty() {
|
||||
output.push('[');
|
||||
arg(degree, output, depth);
|
||||
output.push(']');
|
||||
}
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
|
||||
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
|
||||
match name_of(*chr) {
|
||||
Some(name) => {
|
||||
output.push('\\');
|
||||
output.push_str(name);
|
||||
}
|
||||
None => output.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
output.push(' ');
|
||||
braced(e, output, depth);
|
||||
}
|
||||
|
||||
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
|
||||
output.push_str("\\left");
|
||||
output.push_str(&delim_tex(begin));
|
||||
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
|
||||
for (index, element) in elements.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(&middle);
|
||||
}
|
||||
arg(element, output, depth);
|
||||
}
|
||||
output.push_str("\\right");
|
||||
output.push_str(&delim_tex(end));
|
||||
}
|
||||
|
||||
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
|
||||
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(expression, output, depth);
|
||||
output.push('_');
|
||||
braced(limit, output, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, output, depth);
|
||||
output.push(if upper { '^' } else { '_' });
|
||||
braced(limit, output, depth);
|
||||
} else {
|
||||
output.push_str(if upper { "\\overset" } else { "\\underset" });
|
||||
braced(limit, output, depth);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
let command = match character {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
output.push('\\');
|
||||
output.push_str(command);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
|
||||
if rows.len() == 1
|
||||
&& let Some(row) = rows.first()
|
||||
&& crate::math::has_number_mark(row)
|
||||
{
|
||||
arg(row, output, depth);
|
||||
return;
|
||||
}
|
||||
output.push_str("\\begin{aligned}");
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for child_node in row {
|
||||
match child_node {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (index, part) in run.text.split('&').enumerate() {
|
||||
if index > 0 {
|
||||
output.push('&');
|
||||
s.push_str("\\begin{aligned}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for n in r {
|
||||
match n {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (k, part) in run.text.split('&').enumerate() {
|
||||
if k > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
let mut p = run.clone();
|
||||
p.text = part.to_string();
|
||||
node(&MNode::Run(p), s, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut piece = run.clone();
|
||||
piece.text = part.to_string();
|
||||
run_node(&piece, output);
|
||||
other => node(other, s, depth + 1),
|
||||
}
|
||||
}
|
||||
other => node(other, output, depth + 1),
|
||||
}
|
||||
s.push_str("\\end{aligned}");
|
||||
}
|
||||
MNode::Matrix { rows, .. } => {
|
||||
s.push_str("\\begin{matrix}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for (j, c) in r.iter().enumerate() {
|
||||
if j > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
arg(c, s, depth);
|
||||
}
|
||||
}
|
||||
s.push_str("\\end{matrix}");
|
||||
}
|
||||
MNode::Phant { e, .. } => {
|
||||
s.push_str("\\phantom");
|
||||
braced(e, s, depth);
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{aligned}");
|
||||
}
|
||||
|
||||
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
|
||||
output.push_str("\\begin{matrix}");
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for (column, cell) in row.iter().enumerate() {
|
||||
if column > 0 {
|
||||
output.push('&');
|
||||
}
|
||||
arg(cell, output, depth);
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{matrix}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1135,34 +1076,6 @@ 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,18 +18,6 @@ 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"];
|
||||
|
||||
@@ -351,7 +339,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')],
|
||||
@@ -422,12 +410,10 @@ impl P {
|
||||
fn word(&mut self, stops: &[char]) -> Arg {
|
||||
let start = self.i;
|
||||
let mut end = start;
|
||||
// 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) {
|
||||
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
end += 1;
|
||||
}
|
||||
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
|
||||
let word: String = 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(' '))];
|
||||
@@ -570,7 +556,7 @@ impl P {
|
||||
}
|
||||
out.pop()
|
||||
};
|
||||
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
|
||||
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
|
||||
break;
|
||||
}
|
||||
// The base is already deep: the operator is text (see `too_deep`).
|
||||
@@ -642,7 +628,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(char::is_alphanumeric) {
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
|
||||
@@ -357,8 +357,7 @@ impl Counters {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// 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();
|
||||
let mut chars = def.text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
@@ -376,9 +375,6 @@ impl Counters {
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
@@ -387,29 +383,10 @@ 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
|
||||
|
||||
+841
-21
@@ -13,15 +13,845 @@ use crate::props::{CharProps, ParaProps};
|
||||
use crate::section::SectionProps;
|
||||
use crate::{DocError, Result};
|
||||
|
||||
mod float;
|
||||
mod inline_kinds;
|
||||
mod inline_object;
|
||||
mod run;
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
/// Manual line break (Shift+Enter).
|
||||
pub const LINE_BREAK: char = '\n';
|
||||
/// Page break (Ctrl+Enter).
|
||||
pub const PAGE_BREAK: char = '\u{000C}';
|
||||
/// Column break (Ctrl+Shift+Enter).
|
||||
pub const COLUMN_BREAK: char = '\u{000E}';
|
||||
/// Optional hyphen (Ctrl+-).
|
||||
pub const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
/// Nonbreaking hyphen (Ctrl+Shift+-).
|
||||
pub const NB_HYPHEN: char = '\u{2011}';
|
||||
/// Nonbreaking space (Ctrl+Shift+Space).
|
||||
pub const NBSP: char = '\u{00A0}';
|
||||
|
||||
pub use float::{AltText, Anchor, Float, FloatAlign, MAX_ALT_TEXT, Wrap, WrapText, cap_alt};
|
||||
pub use inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind, ruby_offset, ruby_raise};
|
||||
pub use inline_object::{CanvasStyle, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
|
||||
pub use run::{COLUMN_BREAK, LINE_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, Run, SOFT_HYPHEN, next_rev};
|
||||
static REV: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A fresh revision number (process-unique), used by layout caches to detect edits.
|
||||
pub fn next_rev() -> u64 {
|
||||
REV.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A formatting run: `len` bytes of text sharing `props`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub len: usize,
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub props: CharProps,
|
||||
}
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Wrap {
|
||||
/// In line with text (not floating).
|
||||
#[default]
|
||||
Inline,
|
||||
Square,
|
||||
Tight,
|
||||
Through,
|
||||
TopAndBottom,
|
||||
BehindText,
|
||||
InFrontOfText,
|
||||
}
|
||||
|
||||
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
|
||||
/// Part 1, §20.4.3.7 `ST_WrapText`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapText {
|
||||
/// Both sides of the object.
|
||||
#[default]
|
||||
BothSides,
|
||||
/// Only the left side: the band to the object's right stays empty.
|
||||
Left,
|
||||
/// Only the right side.
|
||||
Right,
|
||||
/// Only whichever side has more room, decided per line.
|
||||
Largest,
|
||||
}
|
||||
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
WrapText::BothSides => "bothSides",
|
||||
WrapText::Left => "left",
|
||||
WrapText::Right => "right",
|
||||
WrapText::Largest => "largest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_both(&self) -> bool {
|
||||
*self == WrapText::BothSides
|
||||
}
|
||||
}
|
||||
|
||||
/// What a floating object's offset is relative to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
Column,
|
||||
Margin,
|
||||
Page,
|
||||
Paragraph,
|
||||
/// The page's left margin area (from the page's left edge to the text).
|
||||
LeftMargin,
|
||||
/// The page's right margin area (from the text to the page's right edge).
|
||||
RightMargin,
|
||||
/// The page's top margin area (from the page's top edge to the text).
|
||||
TopMargin,
|
||||
/// The page's bottom margin area (from the text to the page's bottom edge).
|
||||
BottomMargin,
|
||||
/// The inside margin (left on odd pages, right on even pages when mirrored).
|
||||
InsideMargin,
|
||||
/// The outside margin.
|
||||
OutsideMargin,
|
||||
/// The anchor character (horizontal only).
|
||||
Character,
|
||||
/// The anchor line (vertical only).
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FloatAlign {
|
||||
/// Left or top.
|
||||
Start,
|
||||
Center,
|
||||
/// Right or bottom.
|
||||
End,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
impl FloatAlign {
|
||||
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
|
||||
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
|
||||
match v {
|
||||
"left" | "top" => Some(FloatAlign::Start),
|
||||
"center" => Some(FloatAlign::Center),
|
||||
"right" | "bottom" => Some(FloatAlign::End),
|
||||
"inside" => Some(FloatAlign::Inside),
|
||||
"outside" => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
|
||||
pub fn ooxml(self, horizontal: bool) -> &'static str {
|
||||
match (self, horizontal) {
|
||||
(FloatAlign::Start, true) => "left",
|
||||
(FloatAlign::Start, false) => "top",
|
||||
(FloatAlign::Center, _) => "center",
|
||||
(FloatAlign::End, true) => "right",
|
||||
(FloatAlign::End, false) => "bottom",
|
||||
(FloatAlign::Inside, _) => "inside",
|
||||
(FloatAlign::Outside, _) => "outside",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating placement (ignored when `wrap` is `Inline`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Float {
|
||||
pub wrap: Wrap,
|
||||
/// The sides text wraps on (Square, Tight and Through).
|
||||
#[serde(skip_serializing_if = "WrapText::is_both")]
|
||||
pub wrap_text: WrapText,
|
||||
pub h_rel: Anchor,
|
||||
pub v_rel: Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Alignments within the anchor area; they take precedence over the offsets.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub h_align: Option<FloatAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub v_align: Option<FloatAlign>,
|
||||
/// Distance from surrounding text at the left and right (points).
|
||||
pub dist: f32,
|
||||
/// Distance from surrounding text above and below (points).
|
||||
pub dist_top: f32,
|
||||
pub dist_bottom: f32,
|
||||
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
|
||||
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
|
||||
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
|
||||
pub effect: [f32; 4],
|
||||
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
|
||||
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub rot: f32,
|
||||
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_h: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_v: bool,
|
||||
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
|
||||
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
|
||||
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
|
||||
/// decorative mark). See [`InlineObject::alt_text`].
|
||||
#[serde(skip_serializing_if = "AltText::is_empty")]
|
||||
pub alt: AltText,
|
||||
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
|
||||
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub hidden: bool,
|
||||
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
pub const MAX_ALT_TEXT: usize = 10_000;
|
||||
|
||||
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AltText {
|
||||
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
|
||||
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub text: String,
|
||||
/// The short title (`wp:docPr/@title`).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
/// Decorative: it adds nothing to the content, so screen readers skip it and the
|
||||
/// accessibility checker doesn't ask for a description.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub decorative: bool,
|
||||
}
|
||||
|
||||
impl AltText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty() && self.title.is_empty() && !self.decorative
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` cut to [`MAX_ALT_TEXT`] characters.
|
||||
pub fn cap_alt(s: &str) -> String {
|
||||
s.chars().take(MAX_ALT_TEXT).collect()
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
pub fn spin(&self) -> wordcraft_geom::Spin {
|
||||
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
|
||||
}
|
||||
/// Set the rotation and flips (the angle normalised).
|
||||
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
/// is narrower than its frame, as a wide picture turned 90° is).
|
||||
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeKind {
|
||||
#[default]
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
|
||||
FlowchartOr,
|
||||
Triangle,
|
||||
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
Arrow,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
TextBox,
|
||||
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
|
||||
Freeform,
|
||||
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
|
||||
StraightConnector,
|
||||
ElbowConnector,
|
||||
CurvedConnector,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NoteKind {
|
||||
#[default]
|
||||
Footnote,
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
|
||||
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PTabAlign {
|
||||
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
|
||||
|
||||
/// The OOXML name (`left`, `center`, `right`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabAlign::Left => "left",
|
||||
PTabAlign::Center => "center",
|
||||
PTabAlign::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
|
||||
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
|
||||
/// margins, or the paragraph's indents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabBase {
|
||||
#[default]
|
||||
Margin,
|
||||
Indent,
|
||||
}
|
||||
|
||||
impl PTabBase {
|
||||
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
|
||||
|
||||
/// The OOXML name (`margin`, `indent`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabBase::Margin => "margin",
|
||||
PTabBase::Indent => "indent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
|
||||
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyAlign {
|
||||
#[default]
|
||||
Center,
|
||||
/// Letters spread evenly from edge to edge.
|
||||
DistributeLetter,
|
||||
/// Letters spread evenly with half a gap at each edge.
|
||||
DistributeSpace,
|
||||
Left,
|
||||
Right,
|
||||
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
|
||||
RightVertical,
|
||||
}
|
||||
|
||||
impl RubyAlign {
|
||||
pub const ALL: [RubyAlign; 6] =
|
||||
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
|
||||
|
||||
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
RubyAlign::Center => "center",
|
||||
RubyAlign::DistributeLetter => "distributeLetter",
|
||||
RubyAlign::DistributeSpace => "distributeSpace",
|
||||
RubyAlign::Left => "left",
|
||||
RubyAlign::Right => "right",
|
||||
RubyAlign::RightVertical => "rightVertical",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
|
||||
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
|
||||
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
|
||||
/// baseline and dropping 0.2 em below it.
|
||||
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
|
||||
raise - 0.8 * base_size - 0.2 * size
|
||||
}
|
||||
|
||||
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
|
||||
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
|
||||
offset + 0.8 * base_size + 0.2 * size
|
||||
}
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InlineObject {
|
||||
Image {
|
||||
/// Key in `Document::media`.
|
||||
media: String,
|
||||
/// Display size, points.
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
/// A chart or SmartArt diagram (see [`crate::graphic`]).
|
||||
Graphic {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
graphic: std::sync::Arc<crate::graphic::Graphic>,
|
||||
},
|
||||
Shape {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
/// Drawing id, text warp, connector ends and arrowheads.
|
||||
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
|
||||
extra: crate::connector::ShapeExtra,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
|
||||
/// than a group: it has its own background and outline, and its frame is independent of
|
||||
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
|
||||
/// what lies outside the frame is clipped.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
canvas: Option<CanvasStyle>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text. A `locked` field keeps its result when fields update.
|
||||
Field {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
|
||||
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
|
||||
/// saves; cleared when WordCraft computes a result, like Word's F9.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
|
||||
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
|
||||
/// toggle: never written to files.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
code: bool,
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
|
||||
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
form: String,
|
||||
},
|
||||
/// The start of a field whose result is ordinary content — formatted, possibly spanning
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
|
||||
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
|
||||
/// [`crate::fields`].
|
||||
FieldStart {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::FieldStart`].
|
||||
FieldEnd,
|
||||
/// The start of a content control whose content is what follows, up to the matching
|
||||
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
|
||||
ControlStart {
|
||||
control: Box<crate::control::ContentControl>,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::ControlStart`].
|
||||
ControlEnd,
|
||||
NoteRef {
|
||||
kind: NoteKind,
|
||||
/// `Document::parts` id of the note's story.
|
||||
id: u32,
|
||||
/// Custom mark (empty = automatic number).
|
||||
#[serde(default)]
|
||||
custom: String,
|
||||
},
|
||||
BookmarkStart {
|
||||
name: String,
|
||||
},
|
||||
BookmarkEnd {
|
||||
name: String,
|
||||
},
|
||||
CommentStart {
|
||||
id: u32,
|
||||
},
|
||||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// The start of a range that stays editable while the document is protected (Restrict
|
||||
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
|
||||
/// group, or one editor (`editor`, a user name or address).
|
||||
PermStart {
|
||||
id: u32,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
group: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
editor: String,
|
||||
},
|
||||
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
|
||||
PermEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
|
||||
/// A display equation sits on a line of its own.
|
||||
Equation {
|
||||
linear: String,
|
||||
#[serde(default)]
|
||||
display: bool,
|
||||
/// The structure; empty = parse `linear`.
|
||||
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
|
||||
math: crate::math::Math,
|
||||
},
|
||||
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
|
||||
/// text takes the formatting of the object's run; it reads as the paragraph's text.
|
||||
Ruby {
|
||||
base: String,
|
||||
ruby: String,
|
||||
#[serde(default)]
|
||||
align: RubyAlign,
|
||||
/// Ruby text size, points (`w:hps`).
|
||||
size: f32,
|
||||
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
|
||||
raise: f32,
|
||||
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
|
||||
#[serde(default)]
|
||||
base_size: f32,
|
||||
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
lang: String,
|
||||
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
props: Box<CharProps>,
|
||||
},
|
||||
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
|
||||
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
|
||||
/// text after it lines up there. Text already past that place sends it to the next line.
|
||||
PositionalTab {
|
||||
#[serde(default)]
|
||||
align: PTabAlign,
|
||||
#[serde(default)]
|
||||
relative_to: PTabBase,
|
||||
#[serde(default)]
|
||||
leader: crate::props::TabLeader,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
xml: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. }
|
||||
| InlineObject::Graphic { w, h, float, .. }
|
||||
| InlineObject::Shape { w, h, float, .. }
|
||||
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. }
|
||||
| InlineObject::Graphic { w, h, .. }
|
||||
| InlineObject::Shape { w, h, .. }
|
||||
| InlineObject::Group { w, h, .. } = self
|
||||
{
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
|
||||
*ch_w = nw;
|
||||
*ch_h = nh;
|
||||
}
|
||||
}
|
||||
/// A Drawing Canvas's background and outline (`None` for anything else).
|
||||
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
|
||||
match self {
|
||||
InlineObject::Group { canvas, .. } => canvas.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether this is a Drawing Canvas.
|
||||
pub fn is_canvas(&self) -> bool {
|
||||
self.canvas_style().is_some()
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
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 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
match self {
|
||||
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. }
|
||||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermStart { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::FieldStart { .. }
|
||||
| InlineObject::FieldEnd
|
||||
| InlineObject::ControlStart { .. }
|
||||
| InlineObject::ControlEnd
|
||||
)
|
||||
}
|
||||
/// A drawing's description (alternative text), wherever it is kept.
|
||||
pub fn alt_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
|
||||
pub fn set_alt_text(&mut self, text: &str) -> bool {
|
||||
let text = cap_alt(text);
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A drawing marked decorative.
|
||||
pub fn is_decorative(&self) -> bool {
|
||||
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
|
||||
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
|
||||
match self {
|
||||
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
|
||||
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
|
||||
/// the column. Kept as the run element itself.
|
||||
pub fn note_separator(continuation: bool) -> InlineObject {
|
||||
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
|
||||
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
|
||||
}
|
||||
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
|
||||
pub fn as_note_separator(&self) -> Option<bool> {
|
||||
match self {
|
||||
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
|
||||
"<w:r><w:separator/></w:r>" => Some(false),
|
||||
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text this object contributes to plain-text extraction.
|
||||
pub fn plain_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -252,12 +1082,6 @@ 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> {
|
||||
@@ -501,8 +1325,9 @@ impl Paragraph {
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
crate::cluster::next_cluster(&self.text, self.clamp(off))
|
||||
}
|
||||
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
|
||||
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
|
||||
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
|
||||
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
|
||||
/// at a time, otherwise the previous caret stop.
|
||||
pub fn backspace_from(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
|
||||
@@ -727,11 +1552,6 @@ 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);
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Wrap {
|
||||
/// In line with text (not floating).
|
||||
#[default]
|
||||
Inline,
|
||||
Square,
|
||||
Tight,
|
||||
Through,
|
||||
TopAndBottom,
|
||||
BehindText,
|
||||
InFrontOfText,
|
||||
}
|
||||
|
||||
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
|
||||
/// Part 1, §20.4.3.7 `ST_WrapText`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapText {
|
||||
/// Both sides of the object.
|
||||
#[default]
|
||||
BothSides,
|
||||
/// Only the left side: the band to the object's right stays empty.
|
||||
Left,
|
||||
/// Only the right side.
|
||||
Right,
|
||||
/// Only whichever side has more room, decided per line.
|
||||
Largest,
|
||||
}
|
||||
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
WrapText::BothSides => "bothSides",
|
||||
WrapText::Left => "left",
|
||||
WrapText::Right => "right",
|
||||
WrapText::Largest => "largest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_both(&self) -> bool {
|
||||
*self == WrapText::BothSides
|
||||
}
|
||||
}
|
||||
|
||||
/// What a floating object's offset is relative to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
Column,
|
||||
Margin,
|
||||
Page,
|
||||
Paragraph,
|
||||
/// The page's left margin area (from the page's left edge to the text).
|
||||
LeftMargin,
|
||||
/// The page's right margin area (from the text to the page's right edge).
|
||||
RightMargin,
|
||||
/// The page's top margin area (from the page's top edge to the text).
|
||||
TopMargin,
|
||||
/// The page's bottom margin area (from the text to the page's bottom edge).
|
||||
BottomMargin,
|
||||
/// The inside margin (left on odd pages, right on even pages when mirrored).
|
||||
InsideMargin,
|
||||
/// The outside margin.
|
||||
OutsideMargin,
|
||||
/// The anchor character (horizontal only).
|
||||
Character,
|
||||
/// The anchor line (vertical only).
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FloatAlign {
|
||||
/// Left or top.
|
||||
Start,
|
||||
Center,
|
||||
/// Right or bottom.
|
||||
End,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
impl FloatAlign {
|
||||
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
|
||||
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
|
||||
match v {
|
||||
"left" | "top" => Some(FloatAlign::Start),
|
||||
"center" => Some(FloatAlign::Center),
|
||||
"right" | "bottom" => Some(FloatAlign::End),
|
||||
"inside" => Some(FloatAlign::Inside),
|
||||
"outside" => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
|
||||
pub fn ooxml(self, horizontal: bool) -> &'static str {
|
||||
match (self, horizontal) {
|
||||
(FloatAlign::Start, true) => "left",
|
||||
(FloatAlign::Start, false) => "top",
|
||||
(FloatAlign::Center, _) => "center",
|
||||
(FloatAlign::End, true) => "right",
|
||||
(FloatAlign::End, false) => "bottom",
|
||||
(FloatAlign::Inside, _) => "inside",
|
||||
(FloatAlign::Outside, _) => "outside",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating placement (ignored when `wrap` is `Inline`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Float {
|
||||
pub wrap: Wrap,
|
||||
/// The sides text wraps on (Square, Tight and Through).
|
||||
#[serde(skip_serializing_if = "WrapText::is_both")]
|
||||
pub wrap_text: WrapText,
|
||||
pub h_rel: Anchor,
|
||||
pub v_rel: Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Alignments within the anchor area; they take precedence over the offsets.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub h_align: Option<FloatAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub v_align: Option<FloatAlign>,
|
||||
/// Distance from surrounding text at the left and right (points).
|
||||
pub dist: f32,
|
||||
/// Distance from surrounding text above and below (points).
|
||||
pub dist_top: f32,
|
||||
pub dist_bottom: f32,
|
||||
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
|
||||
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
|
||||
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
|
||||
pub effect: [f32; 4],
|
||||
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
|
||||
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub rot: f32,
|
||||
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_h: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_v: bool,
|
||||
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
|
||||
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
|
||||
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
|
||||
/// decorative mark). See [`InlineObject::alt_text`].
|
||||
#[serde(skip_serializing_if = "AltText::is_empty")]
|
||||
pub alt: AltText,
|
||||
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
|
||||
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub hidden: bool,
|
||||
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
pub const MAX_ALT_TEXT: usize = 10_000;
|
||||
|
||||
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AltText {
|
||||
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
|
||||
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub text: String,
|
||||
/// The short title (`wp:docPr/@title`).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
/// Decorative: it adds nothing to the content, so screen readers skip it and the
|
||||
/// accessibility checker doesn't ask for a description.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub decorative: bool,
|
||||
}
|
||||
|
||||
impl AltText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty() && self.title.is_empty() && !self.decorative
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` cut to [`MAX_ALT_TEXT`] characters.
|
||||
pub fn cap_alt(s: &str) -> String {
|
||||
s.chars().take(MAX_ALT_TEXT).collect()
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
pub fn spin(&self) -> wordcraft_geom::Spin {
|
||||
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
|
||||
}
|
||||
/// Set the rotation and flips (the angle normalised).
|
||||
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
/// is narrower than its frame, as a wide picture turned 90° is).
|
||||
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeKind {
|
||||
#[default]
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
|
||||
FlowchartOr,
|
||||
Triangle,
|
||||
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
/// 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).
|
||||
TextBox,
|
||||
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
|
||||
Freeform,
|
||||
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
|
||||
StraightConnector,
|
||||
ElbowConnector,
|
||||
CurvedConnector,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NoteKind {
|
||||
#[default]
|
||||
Footnote,
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
|
||||
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PTabAlign {
|
||||
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
|
||||
|
||||
/// The OOXML name (`left`, `center`, `right`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabAlign::Left => "left",
|
||||
PTabAlign::Center => "center",
|
||||
PTabAlign::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
|
||||
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
|
||||
/// margins, or the paragraph's indents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabBase {
|
||||
#[default]
|
||||
Margin,
|
||||
Indent,
|
||||
}
|
||||
|
||||
impl PTabBase {
|
||||
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
|
||||
|
||||
/// The OOXML name (`margin`, `indent`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabBase::Margin => "margin",
|
||||
PTabBase::Indent => "indent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
|
||||
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyAlign {
|
||||
#[default]
|
||||
Center,
|
||||
/// Letters spread evenly from edge to edge.
|
||||
DistributeLetter,
|
||||
/// Letters spread evenly with half a gap at each edge.
|
||||
DistributeSpace,
|
||||
Left,
|
||||
Right,
|
||||
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
|
||||
RightVertical,
|
||||
}
|
||||
|
||||
impl RubyAlign {
|
||||
pub const ALL: [RubyAlign; 6] =
|
||||
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
|
||||
|
||||
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
RubyAlign::Center => "center",
|
||||
RubyAlign::DistributeLetter => "distributeLetter",
|
||||
RubyAlign::DistributeSpace => "distributeSpace",
|
||||
RubyAlign::Left => "left",
|
||||
RubyAlign::Right => "right",
|
||||
RubyAlign::RightVertical => "rightVertical",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
|
||||
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
|
||||
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
|
||||
/// baseline and dropping 0.2 em below it.
|
||||
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
|
||||
raise - 0.8 * base_size - 0.2 * size
|
||||
}
|
||||
|
||||
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
|
||||
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
|
||||
offset + 0.8 * base_size + 0.2 * size
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
use super::float::{Float, Wrap, cap_alt};
|
||||
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
|
||||
use super::{CharProps, Deserialize, Serialize};
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InlineObject {
|
||||
Image {
|
||||
/// Key in `Document::media`.
|
||||
media: String,
|
||||
/// Display size, points.
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
/// A chart or SmartArt diagram (see [`crate::graphic`]).
|
||||
Graphic {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
graphic: std::sync::Arc<crate::graphic::Graphic>,
|
||||
},
|
||||
Shape {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
/// Drawing id, text warp, connector ends and arrowheads.
|
||||
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
|
||||
extra: crate::connector::ShapeExtra,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
|
||||
/// than a group: it has its own background and outline, and its frame is independent of
|
||||
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
|
||||
/// what lies outside the frame is clipped.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
canvas: Option<CanvasStyle>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text. A `locked` field keeps its result when fields update.
|
||||
Field {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
|
||||
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
|
||||
/// saves; cleared when WordCraft computes a result, like Word's F9.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
|
||||
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
|
||||
/// toggle: never written to files.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
code: bool,
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
|
||||
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
form: String,
|
||||
},
|
||||
/// The start of a field whose result is ordinary content — formatted, possibly spanning
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
|
||||
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
|
||||
/// [`crate::fields`].
|
||||
FieldStart {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::FieldStart`].
|
||||
FieldEnd,
|
||||
/// The start of a content control whose content is what follows, up to the matching
|
||||
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
|
||||
ControlStart {
|
||||
control: Box<crate::control::ContentControl>,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::ControlStart`].
|
||||
ControlEnd,
|
||||
NoteRef {
|
||||
kind: NoteKind,
|
||||
/// `Document::parts` id of the note's story.
|
||||
id: u32,
|
||||
/// Custom mark (empty = automatic number).
|
||||
#[serde(default)]
|
||||
custom: String,
|
||||
},
|
||||
BookmarkStart {
|
||||
name: String,
|
||||
},
|
||||
BookmarkEnd {
|
||||
name: String,
|
||||
},
|
||||
CommentStart {
|
||||
id: u32,
|
||||
},
|
||||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// The start of a range that stays editable while the document is protected (Restrict
|
||||
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
|
||||
/// group, or one editor (`editor`, a user name or address).
|
||||
PermStart {
|
||||
id: u32,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
group: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
editor: String,
|
||||
},
|
||||
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
|
||||
PermEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
|
||||
/// A display equation sits on a line of its own.
|
||||
Equation {
|
||||
linear: String,
|
||||
#[serde(default)]
|
||||
display: bool,
|
||||
/// The structure; empty = parse `linear`.
|
||||
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
|
||||
math: crate::math::Math,
|
||||
},
|
||||
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
|
||||
/// text takes the formatting of the object's run; it reads as the paragraph's text.
|
||||
Ruby {
|
||||
base: String,
|
||||
ruby: String,
|
||||
#[serde(default)]
|
||||
align: RubyAlign,
|
||||
/// Ruby text size, points (`w:hps`).
|
||||
size: f32,
|
||||
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
|
||||
raise: f32,
|
||||
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
|
||||
#[serde(default)]
|
||||
base_size: f32,
|
||||
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
lang: String,
|
||||
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
props: Box<CharProps>,
|
||||
},
|
||||
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
|
||||
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
|
||||
/// text after it lines up there. Text already past that place sends it to the next line.
|
||||
PositionalTab {
|
||||
#[serde(default)]
|
||||
align: PTabAlign,
|
||||
#[serde(default)]
|
||||
relative_to: PTabBase,
|
||||
#[serde(default)]
|
||||
leader: crate::props::TabLeader,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
xml: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. }
|
||||
| InlineObject::Graphic { w, h, float, .. }
|
||||
| InlineObject::Shape { w, h, float, .. }
|
||||
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. }
|
||||
| InlineObject::Graphic { w, h, .. }
|
||||
| InlineObject::Shape { w, h, .. }
|
||||
| InlineObject::Group { w, h, .. } = self
|
||||
{
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
|
||||
*ch_w = nw;
|
||||
*ch_h = nh;
|
||||
}
|
||||
}
|
||||
/// A Drawing Canvas's background and outline (`None` for anything else).
|
||||
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
|
||||
match self {
|
||||
InlineObject::Group { canvas, .. } => canvas.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether this is a Drawing Canvas.
|
||||
pub fn is_canvas(&self) -> bool {
|
||||
self.canvas_style().is_some()
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
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 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
match self {
|
||||
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. }
|
||||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermStart { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::FieldStart { .. }
|
||||
| InlineObject::FieldEnd
|
||||
| InlineObject::ControlStart { .. }
|
||||
| InlineObject::ControlEnd
|
||||
)
|
||||
}
|
||||
/// A drawing's description (alternative text), wherever it is kept.
|
||||
pub fn alt_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
|
||||
pub fn set_alt_text(&mut self, text: &str) -> bool {
|
||||
let text = cap_alt(text);
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A drawing marked decorative.
|
||||
pub fn is_decorative(&self) -> bool {
|
||||
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
|
||||
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
|
||||
match self {
|
||||
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
|
||||
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
|
||||
/// the column. Kept as the run element itself.
|
||||
pub fn note_separator(continuation: bool) -> InlineObject {
|
||||
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
|
||||
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
|
||||
}
|
||||
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
|
||||
pub fn as_note_separator(&self) -> Option<bool> {
|
||||
match self {
|
||||
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
|
||||
"<w:r><w:separator/></w:r>" => Some(false),
|
||||
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text this object contributes to plain-text extraction.
|
||||
pub fn plain_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize};
|
||||
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
|
||||
/// Manual line break (Shift+Enter).
|
||||
pub const LINE_BREAK: char = '\n';
|
||||
|
||||
/// Page break (Ctrl+Enter).
|
||||
pub const PAGE_BREAK: char = '\u{000C}';
|
||||
|
||||
/// Column break (Ctrl+Shift+Enter).
|
||||
pub const COLUMN_BREAK: char = '\u{000E}';
|
||||
|
||||
/// Optional hyphen (Ctrl+-).
|
||||
pub const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
|
||||
/// Nonbreaking hyphen (Ctrl+Shift+-).
|
||||
pub const NB_HYPHEN: char = '\u{2011}';
|
||||
|
||||
/// Nonbreaking space (Ctrl+Shift+Space).
|
||||
pub const NBSP: char = '\u{00A0}';
|
||||
|
||||
static REV: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A fresh revision number (process-unique), used by layout caches to detect edits.
|
||||
pub fn next_rev() -> u64 {
|
||||
REV.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A formatting run: `len` bytes of text sharing `props`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub len: usize,
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub props: CharProps,
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use super::block::Blocks;
|
||||
use super::position::Pos;
|
||||
use super::{Deserialize, Serialize, props};
|
||||
|
||||
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
|
||||
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
|
||||
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
|
||||
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
|
||||
let Some(largest) = largest else { return Some(min) };
|
||||
if let Some(next) = largest.checked_add(1) {
|
||||
return Some(next.max(min));
|
||||
}
|
||||
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let mut want = min;
|
||||
for i in ids {
|
||||
if i > want {
|
||||
break;
|
||||
}
|
||||
want = want.checked_add(1)?;
|
||||
}
|
||||
Some(want)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PartKind {
|
||||
#[default]
|
||||
Header,
|
||||
Footer,
|
||||
Footnote,
|
||||
Endnote,
|
||||
Comment,
|
||||
TextBox,
|
||||
}
|
||||
|
||||
/// A document's own separators and continuation notice for its footnotes or its endnotes
|
||||
/// (ECMA-376 §17.11: the `w:footnote`/`w:endnote` entries of type `separator`,
|
||||
/// `continuationSeparator` and `continuationNotice`). `None` = the default: a short line above
|
||||
/// the notes, a line across the column above notes continued from the page before, no notice.
|
||||
/// The lines themselves are [`InlineObject::note_separator`] objects in the paragraphs.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NoteSeparators {
|
||||
/// Above the notes on a page.
|
||||
pub separator: Option<Blocks>,
|
||||
/// Above the rest of a note continued from the page before.
|
||||
pub continuation_separator: Option<Blocks>,
|
||||
/// Below the part of a note that continues on the next page.
|
||||
pub continuation_notice: Option<Blocks>,
|
||||
}
|
||||
|
||||
impl NoteSeparators {
|
||||
/// No separator of the document's own: the defaults apply.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.separator.is_none() && self.continuation_separator.is_none() && self.continuation_notice.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A secondary story.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Part {
|
||||
pub kind: PartKind,
|
||||
pub blocks: Blocks,
|
||||
/// A text box's text direction, alignment and link (unused by other parts).
|
||||
#[serde(default, skip_serializing_if = "TextBody::is_default")]
|
||||
pub body: TextBody,
|
||||
}
|
||||
|
||||
/// How a text box shows its text (Shape Format › Text): which way it runs, where it sits between
|
||||
/// the box's top and bottom, and the box its overflow continues in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TextBody {
|
||||
pub vert: props::TextVert,
|
||||
pub anchor: props::VAlign,
|
||||
/// Create Link: the text box (another `TextBox` part, shown by its own shape) the text
|
||||
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
|
||||
/// story; its own blocks are unused. See [`Document::text_box_chains`].
|
||||
pub next: Option<u32>,
|
||||
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
|
||||
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
|
||||
/// `None` is Word's defaults.
|
||||
pub inset: Option<[u32; 4]>,
|
||||
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
|
||||
/// box to its text, so none of the text is cut.
|
||||
pub auto_fit: bool,
|
||||
/// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text
|
||||
/// colour means inside this box, instead of black. Text whose colour comes from its runs,
|
||||
/// styles or the document defaults keeps it (Word, observed with synthetic documents).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_color: Option<props::Rgb>,
|
||||
}
|
||||
|
||||
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
|
||||
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
|
||||
|
||||
/// Widest text box margin kept, EMU (22", the largest page side).
|
||||
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
|
||||
|
||||
impl TextBody {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TextBody::default()
|
||||
}
|
||||
|
||||
/// The internal margins (left, top, right, bottom) in points.
|
||||
pub fn insets_pt(&self) -> [f32; 4] {
|
||||
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most boxes one chain of linked text boxes runs through (hostile files).
|
||||
pub const MAX_LINKED_BOXES: usize = 256;
|
||||
|
||||
/// A range that stays editable while the document is protected; see [`Document::perm_ranges`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PermRange {
|
||||
pub id: u32,
|
||||
/// Editing group (`everyone`, `editors`…); empty when the range names an editor.
|
||||
pub group: String,
|
||||
/// The one editor the range is for; empty for a group.
|
||||
pub editor: String,
|
||||
pub start: Pos,
|
||||
pub end: Pos,
|
||||
}
|
||||
|
||||
impl PermRange {
|
||||
/// Can everyone edit it (rather than one person or group)?
|
||||
pub fn everyone(&self) -> bool {
|
||||
self.group.eq_ignore_ascii_case("everyone")
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StoryRef {
|
||||
#[default]
|
||||
Body,
|
||||
Part(u32),
|
||||
}
|
||||
|
||||
/// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell,
|
||||
/// three more indexes per nesting level. Lexicographic order = document order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Path(pub Vec<u32>);
|
||||
|
||||
impl Path {
|
||||
pub fn top(i: usize) -> Path {
|
||||
Path(vec![i as u32])
|
||||
}
|
||||
/// The block index within its container.
|
||||
pub fn last(&self) -> usize {
|
||||
self.0.last().copied().unwrap_or(0) as usize
|
||||
}
|
||||
pub fn with_last(&self, i: usize) -> Path {
|
||||
let mut p = self.clone();
|
||||
if let Some(l) = p.0.last_mut() {
|
||||
*l = i as u32;
|
||||
}
|
||||
p
|
||||
}
|
||||
/// The container prefix (everything but the last index).
|
||||
pub fn parent(&self) -> &[u32] {
|
||||
self.0.split_last().map(|(_, r)| r).unwrap_or(&[])
|
||||
}
|
||||
/// Nesting depth (0 = top-level story block).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.0.len().saturating_sub(1) / 3
|
||||
}
|
||||
/// The enclosing table cell (table block path, row, cell) if any.
|
||||
pub fn cell(&self) -> Option<(Path, usize, usize)> {
|
||||
if self.0.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = self.0.len();
|
||||
let t = Path(self.0.get(..n - 3)?.to_vec());
|
||||
Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize))
|
||||
}
|
||||
}
|
||||
|
||||
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(ToString::to_string).collect();
|
||||
write!(f, "{}", s.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A position in a document.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Pos {
|
||||
pub story: StoryRef,
|
||||
pub path: Path,
|
||||
pub off: usize,
|
||||
}
|
||||
|
||||
impl Pos {
|
||||
pub fn new(story: StoryRef, path: Path, off: usize) -> Pos {
|
||||
Pos { story, path, off }
|
||||
}
|
||||
pub fn body(block: usize, off: usize) -> Pos {
|
||||
Pos { story: StoryRef::Body, path: Path::top(block), off }
|
||||
}
|
||||
}
|
||||
+1361
-12
File diff suppressed because it is too large
Load Diff
@@ -1,545 +0,0 @@
|
||||
use super::color::{Rgb, TextColor};
|
||||
use super::para_props::Border;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Underline {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Words,
|
||||
Double,
|
||||
Thick,
|
||||
Dotted,
|
||||
Dash,
|
||||
DotDash,
|
||||
DotDotDash,
|
||||
Wave,
|
||||
DoubleWave,
|
||||
}
|
||||
|
||||
impl Underline {
|
||||
pub const ALL: [Underline; 11] = [
|
||||
Underline::None,
|
||||
Underline::Single,
|
||||
Underline::Words,
|
||||
Underline::Double,
|
||||
Underline::Thick,
|
||||
Underline::Dotted,
|
||||
Underline::Dash,
|
||||
Underline::DotDash,
|
||||
Underline::DotDotDash,
|
||||
Underline::Wave,
|
||||
Underline::DoubleWave,
|
||||
];
|
||||
/// The OOXML `w:u/@w:val` name.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Underline::None => "none",
|
||||
Underline::Single => "single",
|
||||
Underline::Words => "words",
|
||||
Underline::Double => "double",
|
||||
Underline::Thick => "thick",
|
||||
Underline::Dotted => "dotted",
|
||||
Underline::Dash => "dash",
|
||||
Underline::DotDash => "dotDash",
|
||||
Underline::DotDotDash => "dotDotDash",
|
||||
Underline::Wave => "wave",
|
||||
Underline::DoubleWave => "wavyDouble",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Underline {
|
||||
Underline::ALL.iter().copied().find(|u| u.ooxml() == s).unwrap_or(match s {
|
||||
"thickDash" | "dashLong" | "dashedHeavy" | "dashLongHeavy" => Underline::Dash,
|
||||
"dottedHeavy" => Underline::Dotted,
|
||||
"wavyHeavy" => Underline::Wave,
|
||||
"dashDotHeavy" => Underline::DotDash,
|
||||
"dashDotDotHeavy" => Underline::DotDotDash,
|
||||
_ => Underline::Single,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
|
||||
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Ligatures {
|
||||
pub standard: bool,
|
||||
pub contextual: bool,
|
||||
pub historical: bool,
|
||||
pub discretional: bool,
|
||||
}
|
||||
|
||||
impl Ligatures {
|
||||
/// Every combination of the four flags.
|
||||
pub fn all() -> impl Iterator<Item = Ligatures> {
|
||||
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
|
||||
}
|
||||
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
|
||||
/// (`standardContextual`).
|
||||
pub fn ooxml(self) -> String {
|
||||
let on: Vec<&str> =
|
||||
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
|
||||
.into_iter()
|
||||
.filter_map(|(on, n)| on.then_some(n))
|
||||
.collect();
|
||||
match on.len() {
|
||||
0 => "none".into(),
|
||||
4 => "all".into(),
|
||||
_ => on
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// `None` for a name [MS-DOCX] doesn't define.
|
||||
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
|
||||
Ligatures::all().find(|l| l.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VertAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Superscript,
|
||||
Subscript,
|
||||
}
|
||||
|
||||
/// Word's 16 highlighter colours (plus none).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Highlight {
|
||||
#[default]
|
||||
None,
|
||||
Yellow,
|
||||
BrightGreen,
|
||||
Turquoise,
|
||||
Pink,
|
||||
Blue,
|
||||
Red,
|
||||
DarkBlue,
|
||||
Teal,
|
||||
Green,
|
||||
Violet,
|
||||
DarkRed,
|
||||
DarkYellow,
|
||||
Gray50,
|
||||
Gray25,
|
||||
Black,
|
||||
White,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
pub const ALL: [Highlight; 17] = [
|
||||
Highlight::None,
|
||||
Highlight::Yellow,
|
||||
Highlight::BrightGreen,
|
||||
Highlight::Turquoise,
|
||||
Highlight::Pink,
|
||||
Highlight::Blue,
|
||||
Highlight::Red,
|
||||
Highlight::DarkBlue,
|
||||
Highlight::Teal,
|
||||
Highlight::Green,
|
||||
Highlight::Violet,
|
||||
Highlight::DarkRed,
|
||||
Highlight::DarkYellow,
|
||||
Highlight::Gray50,
|
||||
Highlight::Gray25,
|
||||
Highlight::Black,
|
||||
Highlight::White,
|
||||
];
|
||||
pub fn rgb(self) -> Option<Rgb> {
|
||||
Some(match self {
|
||||
Highlight::None => return None,
|
||||
Highlight::Yellow => Rgb(255, 255, 0),
|
||||
Highlight::BrightGreen => Rgb(0, 255, 0),
|
||||
Highlight::Turquoise => Rgb(0, 255, 255),
|
||||
Highlight::Pink => Rgb(255, 0, 255),
|
||||
Highlight::Blue => Rgb(0, 0, 255),
|
||||
Highlight::Red => Rgb(255, 0, 0),
|
||||
Highlight::DarkBlue => Rgb(0, 0, 128),
|
||||
Highlight::Teal => Rgb(0, 128, 128),
|
||||
Highlight::Green => Rgb(0, 128, 0),
|
||||
Highlight::Violet => Rgb(128, 0, 128),
|
||||
Highlight::DarkRed => Rgb(128, 0, 0),
|
||||
Highlight::DarkYellow => Rgb(128, 128, 0),
|
||||
Highlight::Gray50 => Rgb(128, 128, 128),
|
||||
Highlight::Gray25 => Rgb(192, 192, 192),
|
||||
Highlight::Black => Rgb(0, 0, 0),
|
||||
Highlight::White => Rgb(255, 255, 255),
|
||||
})
|
||||
}
|
||||
/// OOXML `w:highlight/@w:val`.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "none",
|
||||
Highlight::Yellow => "yellow",
|
||||
Highlight::BrightGreen => "green",
|
||||
Highlight::Turquoise => "cyan",
|
||||
Highlight::Pink => "magenta",
|
||||
Highlight::Blue => "blue",
|
||||
Highlight::Red => "red",
|
||||
Highlight::DarkBlue => "darkBlue",
|
||||
Highlight::Teal => "darkCyan",
|
||||
Highlight::Green => "darkGreen",
|
||||
Highlight::Violet => "darkMagenta",
|
||||
Highlight::DarkRed => "darkRed",
|
||||
Highlight::DarkYellow => "darkYellow",
|
||||
Highlight::Gray50 => "darkGray",
|
||||
Highlight::Gray25 => "lightGray",
|
||||
Highlight::Black => "black",
|
||||
Highlight::White => "white",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Highlight {
|
||||
Highlight::ALL.iter().copied().find(|h| h.ooxml() == s).unwrap_or(Highlight::None)
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "No Color",
|
||||
Highlight::Yellow => "Yellow",
|
||||
Highlight::BrightGreen => "Bright Green",
|
||||
Highlight::Turquoise => "Turquoise",
|
||||
Highlight::Pink => "Pink",
|
||||
Highlight::Blue => "Blue",
|
||||
Highlight::Red => "Red",
|
||||
Highlight::DarkBlue => "Dark Blue",
|
||||
Highlight::Teal => "Teal",
|
||||
Highlight::Green => "Green",
|
||||
Highlight::Violet => "Violet",
|
||||
Highlight::DarkRed => "Dark Red",
|
||||
Highlight::DarkYellow => "Dark Yellow",
|
||||
Highlight::Gray50 => "Gray-50%",
|
||||
Highlight::Gray25 => "Gray-25%",
|
||||
Highlight::Black => "Black",
|
||||
Highlight::White => "White",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
pub struct CharProps {
|
||||
/// Character style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
/// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol
|
||||
/// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is,
|
||||
/// so changing the text's font keeps the symbol. `None` = the run's font.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sym_font: Option<String>,
|
||||
/// Points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline: Option<Underline>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline_color: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub double_strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<TextColor>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub highlight: Option<Highlight>,
|
||||
/// Character shading (background fill).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
/// Character border (`w:bdr`); `style: None` = explicitly no border.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub border: Option<Border>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_align: Option<VertAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_hidden: Option<bool>,
|
||||
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
/// Horizontal scale, percent (100 = normal).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<f32>,
|
||||
/// Raised (+) / lowered (−) position, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<f32>,
|
||||
/// Kerning for fonts at or above this size (points); 0 = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kern: Option<f32>,
|
||||
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
|
||||
/// contextual ligatures.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ligatures: Option<Ligatures>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emboss: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engrave: Option<bool>,
|
||||
/// WordArt text effects: fill, outline, shadow, glow, reflection ([MS-DOCX] `w14:` run
|
||||
/// properties). Overlaid effect by effect.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_effects: Option<Box<crate::wordart::TextEffects>>,
|
||||
/// BCP 47 language tag (proofing).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub no_proof: Option<bool>,
|
||||
/// Right-to-left run (OOXML `w:rtl`): its characters are complex script and read right to left.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rtl: Option<bool>,
|
||||
/// Format the whole run with the complex-script properties below (OOXML `w:cs`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cs: Option<bool>,
|
||||
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_cs: Option<String>,
|
||||
/// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` =
|
||||
/// inherit, then `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_ea: Option<String>,
|
||||
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size_cs: Option<f32>,
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold_cs: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic_cs: Option<bool>,
|
||||
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_bidi: Option<String>,
|
||||
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_ea: Option<String>,
|
||||
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
|
||||
/// Kept for round trip; layout doesn't apply it yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub east_asian_layout: Option<EastAsianLayout>,
|
||||
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Tracked insertion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ins: Option<u32>,
|
||||
/// Tracked deletion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub del: Option<u32>,
|
||||
/// Tracked formatting change (`w:rPrChange`): the formatting before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CharProps>>>,
|
||||
}
|
||||
|
||||
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
|
||||
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
|
||||
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
|
||||
/// `None` when the file doesn't say.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EastAsianLayout {
|
||||
/// East Asian typography run id (`w:id`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<i32>,
|
||||
/// Two lines in one (`w:combine`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine: Option<bool>,
|
||||
/// Brackets around two lines in one (`w:combineBrackets`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine_brackets: Option<CombineBrackets>,
|
||||
/// Horizontal in vertical (`w:vert`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert: Option<bool>,
|
||||
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_compress: Option<bool>,
|
||||
}
|
||||
|
||||
/// Brackets around two lines in one (`ST_CombineBrackets`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CombineBrackets {
|
||||
None,
|
||||
Round,
|
||||
Square,
|
||||
Angle,
|
||||
Curly,
|
||||
}
|
||||
|
||||
impl CombineBrackets {
|
||||
pub const ALL: [CombineBrackets; 5] =
|
||||
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
|
||||
/// OOXML `w:combineBrackets` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
CombineBrackets::None => "none",
|
||||
CombineBrackets::Round => "round",
|
||||
CombineBrackets::Square => "square",
|
||||
CombineBrackets::Angle => "angle",
|
||||
CombineBrackets::Curly => "curly",
|
||||
}
|
||||
}
|
||||
/// The value for `s`, `None` for one the schema doesn't have.
|
||||
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
|
||||
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
|
||||
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
|
||||
/// were before the change, and the revision (author, date) that made it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PropChange<T> {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
/// The properties before the change (formatting only: no revision or link marks).
|
||||
pub old: T,
|
||||
}
|
||||
|
||||
impl<T> PropChange<T> {
|
||||
pub fn boxed(rev: u32, old: T) -> Option<Box<PropChange<T>>> {
|
||||
Some(Box::new(PropChange { rev, old }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip):
|
||||
/// `original` is the number as it was shown before the change.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NumChange {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
pub original: String,
|
||||
}
|
||||
|
||||
impl CharProps {
|
||||
/// The same formatting outside a hyperlink: no link target, and no "Hyperlink" character
|
||||
/// style that came with it. What a paragraph mark or text typed next to a link gets.
|
||||
pub fn unlinked(&self) -> CharProps {
|
||||
let mut c = self.clone();
|
||||
if c.link.take().is_some() && c.style.as_deref() == Some("Hyperlink") {
|
||||
c.style = None;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
|
||||
/// overlaid on `base`.
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
self.minus_text_style(base);
|
||||
self.minus_run_visibility(base);
|
||||
self.minus_script_style(base);
|
||||
self.minus_document_marks(base);
|
||||
self
|
||||
}
|
||||
|
||||
fn minus_text_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn minus_document_marks(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; link, ins, del, ligatures);
|
||||
}
|
||||
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
///
|
||||
/// Plain and complex-script properties inherit independently. Commands that intend to
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
self.overlay_text_style(patch);
|
||||
self.overlay_run_visibility(patch);
|
||||
self.overlay_script_style(patch);
|
||||
self.overlay_document_marks(patch);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_text_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn overlay_document_marks(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == CharProps::default()
|
||||
}
|
||||
/// The font a character of this run is shown in: the symbol's own font when it has one
|
||||
/// ([`Self::sym_font`]), else the run's font.
|
||||
pub fn shown_font(&self) -> Option<&str> {
|
||||
self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref())
|
||||
}
|
||||
/// The same formatting for text typed or painted next to a symbol: without the symbol's own
|
||||
/// font.
|
||||
pub fn without_symbol(mut self) -> CharProps {
|
||||
self.sym_font = None;
|
||||
self
|
||||
}
|
||||
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
|
||||
/// is cleared too, as Word's Clear Formatting does).
|
||||
pub fn cleared(&self) -> CharProps {
|
||||
CharProps {
|
||||
link: self.link.clone(),
|
||||
ins: self.ins,
|
||||
del: self.del,
|
||||
fmt_change: self.fmt_change.clone(),
|
||||
// A symbol stays the symbol it is.
|
||||
sym_font: self.sym_font.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// Just the formatting: without the link, revision marks and tracked formatting change.
|
||||
pub fn formatting(&self) -> CharProps {
|
||||
CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl<'de> Deserialize<'de> for Rgb {
|
||||
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
|
||||
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
|
||||
struct V;
|
||||
impl<'de> Visitor<'de> for V {
|
||||
type Value = Rgb;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
|
||||
}
|
||||
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
|
||||
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
|
||||
let mut c = [0u8; 3];
|
||||
for (i, x) in c.iter_mut().enumerate() {
|
||||
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
|
||||
}
|
||||
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(4, &self));
|
||||
}
|
||||
Ok(Rgb(c[0], c[1], c[2]))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const BLACK: Rgb = Rgb(0, 0, 0);
|
||||
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
||||
/// `RRGGBB` (no `#`).
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
|
||||
}
|
||||
/// Parse `RRGGBB` or `#RRGGBB`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
||||
}
|
||||
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
|
||||
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
|
||||
/// unchanged.
|
||||
pub fn modified(self, name: &str, v: f64) -> Rgb {
|
||||
match name {
|
||||
"lumMod" => adjust_lum(self, |l| l * v),
|
||||
"lumOff" => adjust_lum(self, |l| l + v),
|
||||
"tint" => mix(self, 255.0, v),
|
||||
"shade" => mix(self, 0.0, v),
|
||||
"satMod" => {
|
||||
let (h, sat, l) = to_hsl(self);
|
||||
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
|
||||
}
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
|
||||
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
from_hsl(h, s, l)
|
||||
}
|
||||
/// This colour with its HSL luminance changed by `f`.
|
||||
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(self, f)
|
||||
}
|
||||
pub fn luma(self) -> f32 {
|
||||
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub enum TextColor {
|
||||
#[default]
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
|
||||
impl TextColor {
|
||||
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
|
||||
pub fn parse(s: &str) -> Option<TextColor> {
|
||||
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TextColor {
|
||||
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
|
||||
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, Unexpected};
|
||||
#[derive(Deserialize)]
|
||||
enum Written {
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::String(s) => {
|
||||
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
|
||||
}
|
||||
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
|
||||
Written::Auto => TextColor::Auto,
|
||||
Written::Rgb(c) => TextColor::Rgb(c),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
use super::char_props::{NumChange, PropChange};
|
||||
use super::color::Rgb;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
Distribute,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
/// Alignment is logical (`Left` is the start edge, ISO 29500 `start`): the alignment as seen
|
||||
/// on the page, or the logical one for a visual choice, in a paragraph that reads right to
|
||||
/// left when `rtl` (Left and Right swap; the swap is its own inverse).
|
||||
pub fn visual(self, rtl: bool) -> Align {
|
||||
match self {
|
||||
Align::Left if rtl => Align::Right,
|
||||
Align::Right if rtl => Align::Left,
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
pub enum LineSpacing {
|
||||
/// Multiple of single spacing (1.0 = single, 1.15, 1.5, 2.0 = double).
|
||||
Multiple(f32),
|
||||
/// At least this many points.
|
||||
AtLeast(f32),
|
||||
/// Exactly this many points.
|
||||
Exactly(f32),
|
||||
}
|
||||
|
||||
impl Default for LineSpacing {
|
||||
fn default() -> Self {
|
||||
LineSpacing::Multiple(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Decimal,
|
||||
Bar,
|
||||
/// Clears an inherited stop at this position.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabLeader {
|
||||
#[default]
|
||||
None,
|
||||
Dot,
|
||||
Hyphen,
|
||||
Underscore,
|
||||
MiddleDot,
|
||||
}
|
||||
|
||||
impl TabLeader {
|
||||
pub fn char(self) -> Option<char> {
|
||||
match self {
|
||||
TabLeader::None => None,
|
||||
TabLeader::Dot => Some('.'),
|
||||
TabLeader::Hyphen => Some('-'),
|
||||
TabLeader::Underscore => Some('_'),
|
||||
TabLeader::MiddleDot => Some('·'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
|
||||
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
|
||||
pub const TAB_POS_EPS: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TabStop {
|
||||
/// Points from the paragraph's left indent origin (the text column's left edge).
|
||||
pub pos: f32,
|
||||
#[serde(default)]
|
||||
pub align: TabAlign,
|
||||
#[serde(default)]
|
||||
pub leader: TabLeader,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BorderStyle {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
Dotted,
|
||||
Dashed,
|
||||
Thick,
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
BorderStyle::None => "nil",
|
||||
BorderStyle::Single => "single",
|
||||
BorderStyle::Double => "double",
|
||||
BorderStyle::Dotted => "dotted",
|
||||
BorderStyle::Dashed => "dashed",
|
||||
BorderStyle::Thick => "thick",
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
match s {
|
||||
"nil" | "none" => BorderStyle::None,
|
||||
"double" => BorderStyle::Double,
|
||||
"dotted" => BorderStyle::Dotted,
|
||||
"dashed" | "dashSmallGap" => BorderStyle::Dashed,
|
||||
"thick" => BorderStyle::Thick,
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One border line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Border {
|
||||
pub style: BorderStyle,
|
||||
/// Line width, points.
|
||||
pub width: f32,
|
||||
pub color: Option<Rgb>,
|
||||
/// Distance from the text, points.
|
||||
pub space: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn single(width: f32) -> Border {
|
||||
Border { style: BorderStyle::Single, width, color: None, space: 0.0 }
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.style != BorderStyle::None && self.width > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Borders on the four sides (plus inner borders for tables and runs of bordered paragraphs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Borders {
|
||||
pub top: Option<Border>,
|
||||
pub left: Option<Border>,
|
||||
pub bottom: Option<Border>,
|
||||
pub right: Option<Border>,
|
||||
/// Between paragraphs / inside horizontal (tables).
|
||||
pub between: Option<Border>,
|
||||
/// Inside vertical (tables).
|
||||
pub inside_v: Option<Border>,
|
||||
}
|
||||
|
||||
impl Borders {
|
||||
pub fn all(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: Some(b), inside_v: Some(b) }
|
||||
}
|
||||
pub fn box_(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: None, inside_v: None }
|
||||
}
|
||||
pub fn any_visible(&self) -> bool {
|
||||
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
|
||||
}
|
||||
/// Apply every `Some` side of `patch` on top of `self`.
|
||||
pub fn overlay(&mut self, patch: &Borders) {
|
||||
if patch.top.is_some() {
|
||||
self.top = patch.top;
|
||||
}
|
||||
if patch.left.is_some() {
|
||||
self.left = patch.left;
|
||||
}
|
||||
if patch.bottom.is_some() {
|
||||
self.bottom = patch.bottom;
|
||||
}
|
||||
if patch.right.is_some() {
|
||||
self.right = patch.right;
|
||||
}
|
||||
if patch.between.is_some() {
|
||||
self.between = patch.between;
|
||||
}
|
||||
if patch.inside_v.is_some() {
|
||||
self.inside_v = patch.inside_v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a numbering definition and level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct NumRef {
|
||||
/// `Numbering::nums` id; 0 removes numbering inherited from a style.
|
||||
pub num: u32,
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
|
||||
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TextFrame {
|
||||
/// Width; `None` fits the text.
|
||||
pub w: Option<f32>,
|
||||
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
|
||||
pub h: Option<f32>,
|
||||
pub h_exact: bool,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// `left`, `center`, `right`, `inside`, `outside`.
|
||||
pub x_align: Option<String>,
|
||||
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
|
||||
pub y_align: Option<String>,
|
||||
/// `text` (the column), `margin` or `page`.
|
||||
pub h_anchor: String,
|
||||
/// `text` (the paragraph), `margin` or `page`.
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
}
|
||||
|
||||
/// Paragraph formatting. `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ParaProps {
|
||||
/// Paragraph style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub align: Option<Align>,
|
||||
/// Left indent, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_left: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_right: Option<f32>,
|
||||
/// First-line indent, points; negative = hanging.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_first: Option<f32>,
|
||||
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
|
||||
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
|
||||
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_left_chars: Option<f32>,
|
||||
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_right_chars: Option<f32>,
|
||||
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
|
||||
/// `indent_left_chars`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_first_chars: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
|
||||
/// document or of a table cell, and collapsing with the space after the paragraph above.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before_auto: Option<bool>,
|
||||
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after_auto: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_spacing: Option<LineSpacing>,
|
||||
/// Ignore space before/after between paragraphs of the same style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contextual_spacing: Option<bool>,
|
||||
/// `w:mirrorIndents` (ECMA-376 §17.3.1.18): the left and right indents are the inside and
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_lines: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_break_before: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub widow_control: Option<bool>,
|
||||
/// 0..=8 = Level 1..9; 9 = body text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_level: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub numbering: Option<NumRef>,
|
||||
/// Tab stops (replaces the inherited list; `TabAlign::Clear` stops remove inherited ones).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub borders: Option<Borders>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_hyphens: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
/// Drop cap "In margin" (`w:dropCap="margin"`): the letter hangs in the left margin and the
|
||||
/// text keeps its indent. Unset = dropped into the text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_margin: Option<bool>,
|
||||
/// The drop cap's distance from the text, in points (`w:hSpace`). Unset = 0.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_distance: Option<f32>,
|
||||
/// The drop cap letter's font family (the cap run's `w:rFonts`). Unset = the text's font.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_font: Option<String>,
|
||||
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
|
||||
/// text, which may wrap around it — a footer's page number in a frame, for instance.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<TextFrame>,
|
||||
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
|
||||
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
|
||||
/// Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kinsoku: Option<bool>,
|
||||
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
|
||||
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub word_wrap: Option<bool>,
|
||||
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overflow_punct: Option<bool>,
|
||||
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_line_punct: Option<bool>,
|
||||
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
|
||||
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_de: Option<bool>,
|
||||
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
|
||||
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_dn: Option<bool>,
|
||||
/// Tracked change of the list numbering (`w:numberingChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_change: Option<Box<NumChange>>,
|
||||
/// Tracked formatting change (`w:pPrChange`): the paragraph properties before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<ParaProps>>>,
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
self.overlay_layout(patch);
|
||||
self.overlay_pagination(patch);
|
||||
self.overlay_script_layout(patch);
|
||||
}
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers,
|
||||
drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == ParaProps::default()
|
||||
}
|
||||
/// After setting indents in points: keep the indents in characters (left, right, first line)
|
||||
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
|
||||
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
|
||||
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
|
||||
if on {
|
||||
*chars = Some(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Just the formatting: without the tracked changes.
|
||||
pub fn formatting(&self) -> ParaProps {
|
||||
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
use super::char_props::PropChange;
|
||||
use super::color::Rgb;
|
||||
use super::para_props::{Align, Borders};
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Which way a table cell's text runs (`w:textDirection`, ECMA-376 §17.4.72).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDirection {
|
||||
/// Left to right, lines stacking downwards.
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// Turned 90° clockwise: lines read top to bottom and stack from right to left (`tbRl`).
|
||||
Down,
|
||||
/// Turned 90° counter-clockwise: lines read bottom to top and stack from left to right (`btLr`).
|
||||
Up,
|
||||
}
|
||||
|
||||
impl TextDirection {
|
||||
/// The direction an OOXML `ST_TextDirection` value names (transitional and strict names).
|
||||
/// The East Asian vertical layouts turn like `Down`; unknown values are horizontal.
|
||||
pub fn from_ooxml(v: &str) -> TextDirection {
|
||||
match v {
|
||||
"tbRl" | "tbRlV" | "tbLrV" | "rl" | "rlV" | "lrV" => TextDirection::Down,
|
||||
"btLr" | "lr" => TextDirection::Up,
|
||||
_ => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
/// The transitional OOXML name.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextDirection::Horizontal => "lrTb",
|
||||
TextDirection::Down => "tbRl",
|
||||
TextDirection::Up => "btLr",
|
||||
}
|
||||
}
|
||||
/// Word's Text Direction button: horizontal → down → up → horizontal.
|
||||
pub fn next(self) -> TextDirection {
|
||||
match self {
|
||||
TextDirection::Horizontal => TextDirection::Down,
|
||||
TextDirection::Down => TextDirection::Up,
|
||||
TextDirection::Up => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
pub fn is_turned(self) -> bool {
|
||||
self != TextDirection::Horizontal
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way a text box's text runs (Shape Format › Text › Text Direction; DrawingML
|
||||
/// `a:bodyPr/@vert`, ECMA-376 §21.1.10.83 `ST_TextVerticalType`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextVert {
|
||||
/// Upright, lines stacking downwards (`horz`).
|
||||
#[default]
|
||||
Horz,
|
||||
/// Turned 90° clockwise (`vert`).
|
||||
Vert,
|
||||
/// Turned 270° (`vert270`).
|
||||
Vert270,
|
||||
/// Upright letters stacked one above the other (`wordArtVert`).
|
||||
Stacked,
|
||||
}
|
||||
|
||||
impl TextVert {
|
||||
/// The direction an `ST_TextVerticalType` value names; the East Asian and Mongolian vertical
|
||||
/// layouts turn like `vert`, unknown values are horizontal.
|
||||
pub fn from_ooxml(v: &str) -> TextVert {
|
||||
match v {
|
||||
"vert" | "eaVert" | "mongolianVert" => TextVert::Vert,
|
||||
"vert270" => TextVert::Vert270,
|
||||
"wordArtVert" | "wordArtVertRtl" => TextVert::Stacked,
|
||||
_ => TextVert::Horz,
|
||||
}
|
||||
}
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextVert::Horz => "horz",
|
||||
TextVert::Vert => "vert",
|
||||
TextVert::Vert270 => "vert270",
|
||||
TextVert::Stacked => "wordArtVert",
|
||||
}
|
||||
}
|
||||
/// How its lines turn on the page (stacked text stays upright).
|
||||
pub fn turn(self) -> TextDirection {
|
||||
match self {
|
||||
TextVert::Vert => TextDirection::Down,
|
||||
TextVert::Vert270 => TextDirection::Up,
|
||||
TextVert::Horz | TextVert::Stacked => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a text box's text sits between its top and bottom (Align Text; `a:bodyPr/@anchor`,
|
||||
/// `ST_TextAnchoringType`): reuses [`VAlign`]. Justified and distributed anchoring read as top
|
||||
/// and middle.
|
||||
pub fn valign_from_anchor(v: &str) -> VAlign {
|
||||
match v {
|
||||
"ctr" | "dist" => VAlign::Center,
|
||||
"b" => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ST_TextAnchoringType` value for `a`.
|
||||
pub fn anchor_ooxml(a: VAlign) -> &'static str {
|
||||
match a {
|
||||
VAlign::Top => "t",
|
||||
VAlign::Center => "ctr",
|
||||
VAlign::Bottom => "b",
|
||||
}
|
||||
}
|
||||
|
||||
/// Table-wide properties.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableProps {
|
||||
pub style: Option<String>,
|
||||
/// Preferred width: points (`Some`) or automatic (`None`).
|
||||
pub width: Option<f32>,
|
||||
/// Preferred width as a percentage of the text column (overrides `width`).
|
||||
pub width_pct: Option<f32>,
|
||||
pub align: Option<Align>,
|
||||
/// Indent from the left margin, points.
|
||||
pub indent: Option<f32>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Default cell margins (top, left, bottom, right), points.
|
||||
pub cell_margins: Option<[f32; 4]>,
|
||||
/// Fixed column widths (no auto-fit).
|
||||
pub fixed: bool,
|
||||
/// Table style options ("look"): header row, total row, banded rows, first column, last
|
||||
/// column, banded columns.
|
||||
pub look: TableLook,
|
||||
pub shading: Option<Rgb>,
|
||||
/// Alternative text: the title (`w:tblCaption`).
|
||||
pub caption: Option<String>,
|
||||
/// Alternative text: the description (`w:tblDescription`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub float: Option<TableFloat>,
|
||||
/// Tracked formatting change (`w:tblPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<TableProps>>>,
|
||||
/// Right-to-left table (`w:bidiVisual`): the first logical column stands at the right edge,
|
||||
/// cell margins and left/right borders mirror, and the alignment and indent are measured from
|
||||
/// the right margin.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub bidi_visual: bool,
|
||||
}
|
||||
|
||||
/// Where a floating table sits; the text after it wraps around it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableFloat {
|
||||
/// What `x` is measured from: the column (`Column`), the margin or the page.
|
||||
pub h_rel: crate::para::Anchor,
|
||||
/// What `y` is measured from: the text where the table stands (`Paragraph`), the margin or
|
||||
/// the page.
|
||||
pub v_rel: crate::para::Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub h_align: Option<crate::para::FloatAlign>,
|
||||
pub v_align: Option<crate::para::FloatAlign>,
|
||||
/// Distance from surrounding text: left, top, right, bottom (points).
|
||||
pub dist: [f32; 4],
|
||||
/// Whether it may overlap other floating tables (`w:tblOverlap`); Word's default is yes.
|
||||
pub overlap: bool,
|
||||
}
|
||||
|
||||
impl TableFloat {
|
||||
/// The distances from text, finite and clamped (left, top, right, bottom).
|
||||
pub fn dist_from_text(&self) -> [f32; 4] {
|
||||
self.dist.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableLook {
|
||||
pub header_row: bool,
|
||||
pub total_row: bool,
|
||||
pub banded_rows: bool,
|
||||
pub first_column: bool,
|
||||
pub last_column: bool,
|
||||
pub banded_columns: bool,
|
||||
}
|
||||
|
||||
impl Default for TableLook {
|
||||
fn default() -> Self {
|
||||
TableLook { header_row: true, total_row: false, banded_rows: true, first_column: true, last_column: false, banded_columns: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HeightRule {
|
||||
#[default]
|
||||
Auto,
|
||||
AtLeast,
|
||||
Exact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RowProps {
|
||||
/// Points (meaning depends on `height_rule`).
|
||||
pub height: Option<f32>,
|
||||
pub height_rule: HeightRule,
|
||||
/// Repeat as a header row at the top of each page.
|
||||
pub header: bool,
|
||||
/// Don't let the row break across pages.
|
||||
pub cant_split: bool,
|
||||
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
|
||||
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exceptions: Option<Box<TableProps>>,
|
||||
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
|
||||
/// width of that gap in points (`w:wBefore`).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub grid_before: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub w_before: Option<f32>,
|
||||
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
|
||||
/// (`w:wAfter`).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub grid_after: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub w_after: Option<f32>,
|
||||
/// Tracked formatting change (`w:trPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<RowProps>>>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VMerge {
|
||||
#[default]
|
||||
None,
|
||||
/// First cell of a vertically merged range.
|
||||
Restart,
|
||||
/// Continues the merge from the cell above.
|
||||
Continue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CellProps {
|
||||
/// Preferred width, points.
|
||||
pub width: Option<f32>,
|
||||
/// Preferred width as a percentage of the table's (`w:tcW w:type="pct"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width_pct: Option<f32>,
|
||||
/// Number of grid columns spanned (1 = no horizontal merge).
|
||||
pub span: u32,
|
||||
pub vmerge: VMerge,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
|
||||
/// `Top` beats the style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub valign: Option<VAlign>,
|
||||
/// Cell margins override (top, left, bottom, right), points.
|
||||
pub margins: Option<[f32; 4]>,
|
||||
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub partial_margins: Option<[Option<f32>; 4]>,
|
||||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub text_direction_set: bool,
|
||||
pub no_wrap: bool,
|
||||
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
|
||||
/// empty cells shrinks to its borders.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hide_mark: bool,
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
}
|
||||
|
||||
impl CellProps {
|
||||
/// Resolve top, left, bottom and right margins against the current table defaults.
|
||||
/// A complete override takes precedence over per-side overrides.
|
||||
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
|
||||
if let Some(margins) = self.margins {
|
||||
return margins;
|
||||
}
|
||||
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
|
||||
}
|
||||
}
|
||||
@@ -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(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
size: c.size.unwrap_or(11.0).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(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).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),
|
||||
}
|
||||
@@ -270,12 +270,7 @@ impl ResolvedPara {
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
|
||||
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
|
||||
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
|
||||
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
|
||||
f.w = f.w.filter(|v| *v != 0.0);
|
||||
f.h = f.h.filter(|v| *v != 0.0);
|
||||
f
|
||||
}),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
use super::{Blocks, Deserialize, Document, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Comment {
|
||||
pub author: String,
|
||||
pub initials: String,
|
||||
/// ISO 8601.
|
||||
pub date: String,
|
||||
/// Reply to this comment id.
|
||||
pub parent: Option<u32>,
|
||||
pub resolved: bool,
|
||||
/// `Document::parts` id of the comment text.
|
||||
pub part: u32,
|
||||
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
|
||||
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub durable_id: Option<String>,
|
||||
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, 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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RevisionKind {
|
||||
#[default]
|
||||
Insert,
|
||||
Delete,
|
||||
Format,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Revision {
|
||||
pub kind: RevisionKind,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
/// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision,
|
||||
/// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain
|
||||
/// insertion or deletion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub move_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
pub fn new(kind: RevisionKind, author: impl Into<String>, date: impl Into<String>) -> Revision {
|
||||
Revision { kind, author: author.into(), date: date.into(), move_name: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The move name of revision `idx`, when it is one end of a move.
|
||||
pub fn move_name(&self, idx: Option<u32>) -> Option<&str> {
|
||||
idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// A building block (Insert › Quick Parts, AutoText…) kept in the document's glossary
|
||||
/// (ECMA-376 §17.12: `w:docPart`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct BuildingBlock {
|
||||
/// The entry's name (`w:docPartPr/w:name`).
|
||||
pub name: String,
|
||||
/// Its gallery (`w:category/w:gallery`: `docParts` for Quick Parts, `autoTxt` for AutoText…).
|
||||
pub gallery: String,
|
||||
/// Its category within the gallery (`w:category/w:name`, e.g. "General").
|
||||
pub category: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// The entry's identity (`w:guid`), kept as read.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub guid: String,
|
||||
/// The paragraph style it's associated with (`w:style`), kept as read.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// Its types (`w:types/w:type`: `normal`, `bbPlcHdr` for a content control's placeholder…).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub types: Vec<String>,
|
||||
/// How it's inserted (`w:behaviors/w:behavior`: `content`, `p`, `pg`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub behaviors: Vec<String>,
|
||||
/// The content (`w:docPartBody`).
|
||||
pub blocks: Blocks,
|
||||
}
|
||||
|
||||
/// The gallery Quick Parts saves to (`w:gallery w:val="docParts"`).
|
||||
pub const QUICK_PARTS_GALLERY: &str = "docParts";
|
||||
|
||||
/// The AutoText gallery (`w:gallery w:val="autoTxt"`).
|
||||
pub const AUTO_TEXT_GALLERY: &str = "autoTxt";
|
||||
@@ -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, Hindi, Thai,
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
@@ -268,8 +268,6 @@ 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),
|
||||
@@ -310,6 +308,8 @@ impl NumFormat {
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
| NumFormat::ArabicAbjad
|
||||
| NumFormat::HindiVowels
|
||||
| NumFormat::HindiConsonants
|
||||
| NumFormat::HindiNumbers
|
||||
@@ -399,27 +399,6 @@ 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",
|
||||
@@ -941,19 +920,6 @@ 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() };
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
use super::{Deserialize, Rgb, Serialize, math, section, styles};
|
||||
|
||||
/// A bibliography source.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Source {
|
||||
/// Unique tag cited by CITATION fields.
|
||||
pub tag: String,
|
||||
/// book, bookSection, article (journal), periodical, conference, report, website, film, other
|
||||
pub kind: String,
|
||||
/// "Last, First; Last, First"
|
||||
pub author: String,
|
||||
/// A corporate author ("World Health Organization"): written as it is, never split into
|
||||
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
|
||||
/// #4848).
|
||||
pub corporate: String,
|
||||
pub title: String,
|
||||
pub year: String,
|
||||
pub publisher: String,
|
||||
pub city: String,
|
||||
pub journal: String,
|
||||
pub volume: String,
|
||||
pub pages: String,
|
||||
pub url: String,
|
||||
/// More bibliography fields (Create Source › Show All Bibliography Fields).
|
||||
pub editor: String,
|
||||
pub edition: String,
|
||||
pub issue: String,
|
||||
pub month: String,
|
||||
pub day: String,
|
||||
/// Date accessed (websites).
|
||||
pub accessed: String,
|
||||
pub doi: String,
|
||||
}
|
||||
|
||||
/// A custom document property (File › Info › Properties › Custom). Citation managers keep
|
||||
/// their per-document preferences here (Zotero: `ZOTERO_PREF_1`, `ZOTERO_PREF_2`…).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CustomProp {
|
||||
pub name: String,
|
||||
/// OOXML variant type: `lpwstr`, `i4`, `r8`, `bool`, `filetime`…, or `raw` when `value` is
|
||||
/// the property's XML content kept verbatim (vectors, blobs).
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CoreProps {
|
||||
pub title: String,
|
||||
pub subject: String,
|
||||
pub creator: String,
|
||||
pub keywords: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub last_modified_by: String,
|
||||
pub created: String,
|
||||
pub modified: String,
|
||||
pub revision: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub track_changes: bool,
|
||||
/// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off
|
||||
/// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an
|
||||
/// ordinary deletion and insertion.
|
||||
pub track_moves: bool,
|
||||
/// Default tab stop interval, points.
|
||||
pub default_tab: f32,
|
||||
pub even_odd_headers: bool,
|
||||
pub mirror_margins: bool,
|
||||
pub auto_hyphenation: bool,
|
||||
/// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking
|
||||
/// it at a word boundary would leave more than this empty at its end.
|
||||
pub hyphenation_zone: f32,
|
||||
/// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off).
|
||||
pub hyphenate_caps: bool,
|
||||
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
|
||||
pub consecutive_hyphen_limit: u32,
|
||||
pub page_color: Option<Rgb>,
|
||||
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
|
||||
pub page_picture: Option<PagePicture>,
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Theme fonts (major = headings, minor = body).
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
pub theme_name: String,
|
||||
pub footnote_format: section::NumFormat,
|
||||
pub endnote_format: section::NumFormat,
|
||||
/// The document's footnote and endnote placement, start and restart (`w:footnotePr` /
|
||||
/// `w:endnotePr` in the settings); sections may override them.
|
||||
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
|
||||
pub footnote_pr: section::NoteProps,
|
||||
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
|
||||
pub endnote_pr: section::NoteProps,
|
||||
/// Restrict Editing, when enforced: `readOnly`, `comments`, `trackedChanges`, `forms`, or
|
||||
/// `none` when only formatting is restricted (`w:documentProtection w:edit`).
|
||||
pub protection: Option<String>,
|
||||
/// Restrict Editing limits formatting to the styles that aren't locked, and forbids direct
|
||||
/// formatting (`w:formatting="1"`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub protect_formatting: bool,
|
||||
/// The protection password's hash, as the file's `w:documentProtection` attributes
|
||||
/// (`w:algorithmName`, `w:hashValue`, `w:saltValue`, `w:spinCount`, or the older
|
||||
/// `w:cryptAlgorithmSid`, `w:hash`, `w:salt`…). Empty: no password.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub protect_hash: Vec<(String, String)>,
|
||||
/// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word
|
||||
/// 2013 and later, which places a table's border at the margin rather than its text and lets
|
||||
/// justified lines shrink their spaces to fit more text.
|
||||
pub compat_mode: u32,
|
||||
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
|
||||
/// draws one that isn't installed in a serif face.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub roman_fonts: Vec<String>,
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
|
||||
/// after plus the second's space before, not the larger of the two.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// `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,
|
||||
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
|
||||
pub grid_h: f32,
|
||||
pub grid_v: f32,
|
||||
/// Equation options (`m:mathPr`), when the document has them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub math: Option<math::MathProps>,
|
||||
/// Citation and bibliography style (References › Style) by name, when the document
|
||||
/// records one (OOXML's `b:Sources/@StyleName`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bib_style: Option<String>,
|
||||
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
|
||||
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attached_template: Option<String>,
|
||||
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
|
||||
pub link_styles: bool,
|
||||
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
|
||||
/// without a fit mode.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub zoom: Option<Zoom>,
|
||||
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
|
||||
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
|
||||
/// the document's creator and last-modified-by. The open document keeps its real values.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_personal_info: bool,
|
||||
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
|
||||
/// comments and tracked changes are saved without `w:date`.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Zoom {
|
||||
/// 10–500.
|
||||
pub percent: u32,
|
||||
pub fit: Option<ZoomFit>,
|
||||
}
|
||||
|
||||
/// View › Zoom's fit modes a document can be saved in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ZoomFit {
|
||||
OnePage,
|
||||
PageWidth,
|
||||
TextWidth,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
pub const LEGACY_COMPAT_MODE: u32 = 12;
|
||||
|
||||
/// The compatibility mode of documents created by Word 2013 and later, and by WordCraft.
|
||||
pub const COMPAT_MODE_CURRENT: u32 = 15;
|
||||
|
||||
/// Word's hyphenation zone when a document doesn't set one (0.25"), points.
|
||||
pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
|
||||
|
||||
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
|
||||
pub const DEFAULT_GRID: f32 = 9.0;
|
||||
|
||||
/// A picture fill for the page background (`v:background/v:fill` with an image).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PagePicture {
|
||||
/// Key into [`Document::media`].
|
||||
pub media: String,
|
||||
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
|
||||
pub fill_type: String,
|
||||
/// `o:title` of the fill (texture name), kept for round trip.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Watermark {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
pub semitransparent: bool,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Watermark {
|
||||
text: "DRAFT".into(),
|
||||
font: styles::BODY_FONT.into(),
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
/// `rotation:315`).
|
||||
pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] {
|
||||
if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) {
|
||||
return [w.min(4000.0), h.min(4000.0)];
|
||||
}
|
||||
let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0));
|
||||
let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = self.text.chars().take(200).count().max(1) as f32;
|
||||
[width, (width / (0.55 * chars)).clamp(12.0, 400.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Our default theme colours.
|
||||
pub const THEME_COLORS: [Rgb; 12] = [
|
||||
Rgb(0x00, 0x00, 0x00),
|
||||
Rgb(0xFF, 0xFF, 0xFF),
|
||||
Rgb(0x0E, 0x28, 0x41),
|
||||
Rgb(0xE8, 0xE8, 0xE8),
|
||||
Rgb(0x15, 0x60, 0x82),
|
||||
Rgb(0xE9, 0x71, 0x32),
|
||||
Rgb(0x19, 0x6B, 0x24),
|
||||
Rgb(0x0F, 0x9E, 0xD5),
|
||||
Rgb(0xA0, 0x2B, 0x93),
|
||||
Rgb(0x4E, 0xA7, 0x2E),
|
||||
Rgb(0x46, 0x78, 0x86),
|
||||
Rgb(0x96, 0x60, 0x7D),
|
||||
];
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
track_changes: false,
|
||||
track_moves: true,
|
||||
default_tab: 36.0,
|
||||
even_odd_headers: false,
|
||||
mirror_margins: false,
|
||||
auto_hyphenation: false,
|
||||
hyphenation_zone: DEFAULT_HYPHENATION_ZONE,
|
||||
hyphenate_caps: true,
|
||||
consecutive_hyphen_limit: 0,
|
||||
page_color: None,
|
||||
page_picture: None,
|
||||
watermark: None,
|
||||
major_font: styles::HEADING_FONT.into(),
|
||||
minor_font: styles::BODY_FONT.into(),
|
||||
theme_colors: THEME_COLORS.to_vec(),
|
||||
theme_name: "Craft".into(),
|
||||
footnote_format: section::NumFormat::Decimal,
|
||||
endnote_format: section::NumFormat::LowerRoman,
|
||||
footnote_pr: section::NoteProps::default(),
|
||||
endnote_pr: section::NoteProps::default(),
|
||||
protection: None,
|
||||
protect_formatting: false,
|
||||
protect_hash: Vec::new(),
|
||||
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,
|
||||
math: None,
|
||||
bib_style: None,
|
||||
attached_template: None,
|
||||
link_styles: false,
|
||||
zoom: None,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-223
@@ -48,12 +48,6 @@ 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)]
|
||||
@@ -77,80 +71,6 @@ pub const MAX_COLS: usize = 63;
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
/// and whether any cell touches them (Cut clears the same cells it copies).
|
||||
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
|
||||
let ((r0, r1), (g0, g1)) = (rows, cols);
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
|
||||
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
|
||||
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
|
||||
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
|
||||
let mut any = false;
|
||||
for _ in 0..=MAX_COLS.saturating_add(1) {
|
||||
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g < hi && end > lo {
|
||||
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
any = found;
|
||||
if (nlo, nhi) == (lo, hi) {
|
||||
break;
|
||||
}
|
||||
(lo, hi) = (nlo, nhi);
|
||||
}
|
||||
(lo, hi, any)
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
|
||||
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
|
||||
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
|
||||
/// that continues a vertical merge from a row left out starts the merge instead.
|
||||
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
|
||||
let (r0, r1) = rows;
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
let (lo, hi, any) = self.slice_bounds(rows, cols);
|
||||
let mut out_rows = Vec::new();
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::new();
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if any && g < hi && end > lo {
|
||||
cells.push(cell.clone());
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
|
||||
}
|
||||
}
|
||||
if let Some(first) = out_rows.first_mut() {
|
||||
for c in &mut first.cells {
|
||||
if c.props.vmerge == VMerge::Continue {
|
||||
c.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
let whole = lo == 0 && hi >= self.grid.len();
|
||||
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
|
||||
let mut props = self.props.clone();
|
||||
if !whole {
|
||||
// Part of the columns: as wide as they are, not the whole table's width.
|
||||
if props.width.is_some() || props.width_pct.is_some() {
|
||||
props.width = Some(grid.iter().sum());
|
||||
}
|
||||
props.width_pct = None;
|
||||
}
|
||||
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
|
||||
}
|
||||
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
@@ -177,8 +97,7 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> 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());
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
@@ -246,54 +165,6 @@ 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 {
|
||||
@@ -322,8 +193,7 @@ impl Table {
|
||||
match idx {
|
||||
Some((i, true)) => {
|
||||
if let Some(c) = row.cells.get_mut(i) {
|
||||
// From the clamped span: a `.json` file can carry any `u32` (#3637).
|
||||
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
|
||||
c.props.span += 1;
|
||||
}
|
||||
}
|
||||
Some((i, false)) => {
|
||||
@@ -353,7 +223,7 @@ impl Table {
|
||||
for (i, c) in row.cells.iter_mut().enumerate() {
|
||||
if g < x + c.span() {
|
||||
if c.span() > 1 {
|
||||
c.props.span = (c.span() - 1) as u32;
|
||||
c.props.span -= 1;
|
||||
} else {
|
||||
rm = Some(i);
|
||||
}
|
||||
@@ -377,7 +247,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 = (first.span() + c.span()).min(MAX_COLS) as u32;
|
||||
first.props.span += c.props.span.max(1);
|
||||
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
|
||||
first.blocks.extend(nonempty);
|
||||
}
|
||||
@@ -640,69 +510,6 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
|
||||
fn merged() -> Table {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
t.merge(1, 2, 2, 2);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
t
|
||||
}
|
||||
|
||||
fn spans(t: &Table) -> Vec<Vec<usize>> {
|
||||
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
|
||||
/// other rows widen with it, so the copy isn't ragged.
|
||||
#[test]
|
||||
fn slice_widens_to_a_merged_cell_in_every_row() {
|
||||
let t = merged().slice((0, 1), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
|
||||
// Rows without the merged cell: just the selected columns.
|
||||
let t = merged().slice((1, 2), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
|
||||
}
|
||||
|
||||
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
|
||||
#[test]
|
||||
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
|
||||
let t = merged().slice((2, 2), (0, 2));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
|
||||
// Both rows kept: the merge is kept as it is.
|
||||
let t = merged().slice((1, 2), (2, 2));
|
||||
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
}
|
||||
|
||||
/// #1744 review: part of the columns is as wide as they are; content controls around rows
|
||||
/// and the table aren't copied.
|
||||
#[test]
|
||||
fn slice_width_and_controls() {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
|
||||
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
|
||||
t.controls = wrap.clone();
|
||||
t.rows[1].controls = wrap;
|
||||
let part = t.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
|
||||
let whole = t.slice((0, 2), (0, 2));
|
||||
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
|
||||
let row = t.slice((1, 1), (0, 2));
|
||||
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
|
||||
// A table sized only by a percentage keeps a width for the part.
|
||||
let mut pct = Table::new(3, 3, 300.0);
|
||||
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
|
||||
let part = pct.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
|
||||
// Nothing in range: no rows.
|
||||
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
|
||||
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
@@ -742,32 +549,6 @@ 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);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//! 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())))
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -92,45 +90,6 @@ 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));
|
||||
@@ -182,72 +141,6 @@ 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");
|
||||
@@ -564,155 +457,3 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_linear_time() {
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
const COUNT: usize = 2_000;
|
||||
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
let timer = std::time::Instant::now();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables.len(), COUNT);
|
||||
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
|
||||
assert_eq!(*path, Path::top(0));
|
||||
assert_eq!(*nth, i);
|
||||
assert!(*is_open);
|
||||
}
|
||||
|
||||
assert!(d.has_unbalanced_controls());
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, COUNT);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
|
||||
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
|
||||
assert!(tbl.controls.open.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
let mut d = Document::new();
|
||||
let mut t0 = Table::new(1, 1, 100.0);
|
||||
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
|
||||
t0.controls.close = 1;
|
||||
|
||||
let mut t1 = Table::new(1, 1, 100.0);
|
||||
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
|
||||
|
||||
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
|
||||
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, 4);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
|
||||
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
|
||||
assert_eq!(res0.controls.open.len(), 1);
|
||||
assert_eq!(res0.controls.open[0].tag, "t0_2");
|
||||
assert_eq!(res0.controls.close, 1);
|
||||
|
||||
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
use super::*;
|
||||
use crate::para::{InlineObject, NoteKind, OBJ};
|
||||
|
||||
fn reference(id: u32, kind: NoteKind) -> InlineObject {
|
||||
InlineObject::NoteRef { kind, id, custom: "*".into() }
|
||||
}
|
||||
|
||||
fn styled(len: usize, style: &str) -> Run {
|
||||
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
|
||||
for (foot, end, expected) in [
|
||||
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
|
||||
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
] {
|
||||
let mut d = Document::new();
|
||||
let note = |style: &str| {
|
||||
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
|
||||
p.props.style = Some(style.into());
|
||||
p.mark.style = Some(style.into());
|
||||
vec![para_block(p)]
|
||||
};
|
||||
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
|
||||
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
|
||||
let p = Paragraph {
|
||||
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
|
||||
objects: vec![
|
||||
reference(f, NoteKind::Footnote),
|
||||
InlineObject::BookmarkStart { name: "kept".into() },
|
||||
reference(e, NoteKind::Endnote),
|
||||
reference(999, NoteKind::Footnote),
|
||||
reference(f, NoteKind::Footnote),
|
||||
],
|
||||
runs: vec![
|
||||
styled(2, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
],
|
||||
..Paragraph::new()
|
||||
};
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
|
||||
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
|
||||
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
|
||||
for (story, path) in [
|
||||
(StoryRef::Body, Path::top(0)),
|
||||
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
|
||||
(StoryRef::Part(h), Path::top(0)),
|
||||
(StoryRef::Part(t), Path::top(0)),
|
||||
] {
|
||||
let got = d.para(story, &path).unwrap();
|
||||
assert_eq!(got.text, p.text);
|
||||
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
|
||||
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
|
||||
assert_eq!(got.objects[1], p.objects[1]);
|
||||
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
|
||||
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
|
||||
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
|
||||
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
|
||||
}
|
||||
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
|
||||
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
|
||||
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
|
||||
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
|
||||
assert_eq!(got.mark.style, got.props.style);
|
||||
}
|
||||
let mut missing = Paragraph {
|
||||
text: format!("{OBJ}{OBJ}"),
|
||||
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
|
||||
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
|
||||
..Paragraph::new()
|
||||
};
|
||||
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
|
||||
d.body = vec![para_block(missing)];
|
||||
d.convert_notes(true, true);
|
||||
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(
|
||||
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dense(n: usize) -> Document {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
|
||||
d.body = vec![para_block(Paragraph {
|
||||
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
|
||||
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
|
||||
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
|
||||
..Paragraph::new()
|
||||
})];
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
let convert = |input: &Document| {
|
||||
let mut d = input.clone();
|
||||
assert_eq!(d.convert_notes(true, false), 1);
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.objects.len();
|
||||
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
|
||||
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
|
||||
std::hint::black_box(d);
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::PartKind;
|
||||
use super::position::Path;
|
||||
use super::{InlineObject, Numbering, numbering, para, section, styles};
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
/// The note and text box stories an object shows, for `Document::reachable`.
|
||||
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
|
||||
match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
Block::Table(t) => {
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
prefix.push(r as u32);
|
||||
prefix.push(c as u32);
|
||||
walk(&cell.blocks, prefix, out, depth + 1);
|
||||
prefix.pop();
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside.
|
||||
pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize, (a, b): (&[u32], &[u32])) {
|
||||
use std::cmp::Ordering::{Greater, Less};
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
// Where the paths starting with `q` lie against `x`: all before, all after, or either.
|
||||
let side = |q: &[u32], x: &[u32]| {
|
||||
let m = q.len().min(x.len());
|
||||
q[..m].cmp(&x[..m])
|
||||
};
|
||||
for (i, blk) in bl.iter().enumerate() {
|
||||
prefix.push(i as u32);
|
||||
if side(prefix, b) == Greater {
|
||||
prefix.pop();
|
||||
break;
|
||||
}
|
||||
if side(prefix, a) != Less {
|
||||
match &**blk {
|
||||
Block::Para(_) => {
|
||||
if prefix.as_slice() >= a && prefix.as_slice() <= b {
|
||||
out.push(Path(prefix.clone()));
|
||||
}
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
prefix.push(r as u32);
|
||||
prefix.push(c as u32);
|
||||
if side(prefix, a) != Less && side(prefix, b) != Greater {
|
||||
walk_between(&cell.blocks, prefix, out, depth + 1, (a, b));
|
||||
}
|
||||
prefix.pop();
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Which text of a paragraph [`text_of`] collects.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TextMode {
|
||||
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
|
||||
Plain,
|
||||
/// As shown on screen ([`Paragraph::shown_text`]).
|
||||
Shown,
|
||||
/// Without tracked deletions ([`Paragraph::final_text`]).
|
||||
Final,
|
||||
}
|
||||
|
||||
pub(crate) fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&match mode {
|
||||
TextMode::Plain => p.plain_text(),
|
||||
TextMode::Shown => p.shown_text(),
|
||||
TextMode::Final => p.final_text(),
|
||||
});
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, mode);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn story_word_count_of(
|
||||
bl: &Blocks,
|
||||
numbering: &Numbering,
|
||||
styles: &styles::StyleSheet,
|
||||
counters: &mut numbering::Counters,
|
||||
depth: usize,
|
||||
) -> usize {
|
||||
if depth > 16 {
|
||||
return 0;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
if let Some(n) = n
|
||||
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
|
||||
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
|
||||
{
|
||||
total = total.saturating_add(count_words(&label));
|
||||
}
|
||||
total = total.saturating_add(count_words(&p.plain_text()));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
for c in &row.cells {
|
||||
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
+5
-34
@@ -92,7 +92,11 @@ 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 | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
|
||||
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::Star => (0..10)
|
||||
.map(|i| {
|
||||
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
|
||||
@@ -122,26 +126,6 @@ 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;
|
||||
|
||||
@@ -230,16 +214,6 @@ 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 [
|
||||
@@ -249,9 +223,6 @@ mod tests {
|
||||
ShapeKind::RightTriangle,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::ArrowLeft,
|
||||
ShapeKind::ArrowUp,
|
||||
ShapeKind::ArrowDown,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
] {
|
||||
|
||||
@@ -20,6 +20,5 @@ log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -90,11 +90,8 @@ 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(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
|
||||
let mut h = Sha1::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
@@ -295,13 +292,8 @@ 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(
|
||||
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
|
||||
));
|
||||
return Err(unsupported("AES-encrypted .doc files are not supported"));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
|
||||
@@ -81,18 +81,12 @@ 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));
|
||||
@@ -196,10 +190,7 @@ 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 | 69 => ShapeKind::Arrow,
|
||||
66 => ShapeKind::ArrowLeft,
|
||||
67 => ShapeKind::ArrowDown,
|
||||
68 => ShapeKind::ArrowUp,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
74 => ShapeKind::Heart,
|
||||
202 => ShapeKind::TextBox,
|
||||
|
||||
+29
-191
@@ -49,91 +49,6 @@ 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
|
||||
@@ -173,16 +88,10 @@ 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) = open_container(bytes) else { return false };
|
||||
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -193,7 +102,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 = open_container(bytes)?;
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
@@ -259,14 +168,13 @@ 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::for_input(word.len().saturating_add(data.len()));
|
||||
let mut pics = media::Pictures::default();
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -282,7 +190,6 @@ 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
|
||||
@@ -391,7 +298,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -428,8 +334,6 @@ 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.
|
||||
@@ -461,14 +365,6 @@ 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
|
||||
@@ -480,7 +376,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<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
fn walk(ctx: &WalkCtx, 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();
|
||||
@@ -489,7 +385,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
// 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).
|
||||
@@ -595,7 +490,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
f.depth -= 1;
|
||||
field = Some(f);
|
||||
} else {
|
||||
finish_field(&mut pb, f, ctx.work);
|
||||
finish_field(&mut pb, f);
|
||||
}
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
@@ -607,15 +502,12 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
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.clone(),
|
||||
media: p.key,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt,
|
||||
alt: p.alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
@@ -627,12 +519,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
'\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());
|
||||
}
|
||||
@@ -645,16 +532,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\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),
|
||||
'\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()),
|
||||
// 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(), &mut memo),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -677,44 +564,19 @@ 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.
|
||||
///
|
||||
/// 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) {
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
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) {
|
||||
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);
|
||||
for (c, mut props) in result {
|
||||
props.link = Some(target.clone());
|
||||
pb.push(c, 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() });
|
||||
}
|
||||
@@ -840,28 +702,19 @@ 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<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
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(),
|
||||
let default;
|
||||
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
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
|
||||
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);
|
||||
// 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;
|
||||
@@ -886,20 +739,8 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
match prm {
|
||||
PrmRef::None => {}
|
||||
PrmRef::Sprm0 { isprm, val } => {
|
||||
@@ -912,9 +753,6 @@ 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);
|
||||
|
||||
+26
-68
@@ -17,24 +17,6 @@ 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
|
||||
@@ -46,17 +28,11 @@ 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).
|
||||
@@ -68,11 +44,9 @@ 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,
|
||||
}
|
||||
@@ -90,24 +64,19 @@ 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 !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);
|
||||
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();
|
||||
}
|
||||
self.by_fc.get(&fc_pic)?.as_ref()
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
pic
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
@@ -117,13 +86,16 @@ impl Pictures {
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, self)? {
|
||||
match locate(data, fc_pic, room)? {
|
||||
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, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,23 +109,9 @@ impl Pictures {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
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());
|
||||
}
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
@@ -193,7 +151,7 @@ struct Located {
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
|
||||
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
@@ -202,8 +160,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);
|
||||
/// 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> {
|
||||
/// a shape's picture is decoded only up to `room` bytes.
|
||||
fn locate(data: &[u8], fc_pic: u32, room: usize) -> 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;
|
||||
@@ -222,12 +180,12 @@ fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
|
||||
// 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, key, props });
|
||||
return Some(Found::Shape { w, h, blip, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
@@ -239,16 +197,16 @@ fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `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)> {
|
||||
/// `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)> {
|
||||
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 key = pics.decode_bse(fbse, data);
|
||||
Some((key, sp.props))
|
||||
let blip = officeart::fbse_blip(fbse, data, room)?;
|
||||
Some((blip, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
||||
+14
-90
@@ -89,105 +89,29 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32,
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data).
|
||||
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))?;
|
||||
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 b.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let c_data = u16::from_le_bytes([b[2], b[3]]) as usize;
|
||||
if c_data > MAX {
|
||||
return None;
|
||||
}
|
||||
let mut at = head + 4;
|
||||
let mut at = 6usize;
|
||||
let mut out = Vec::with_capacity(c_data);
|
||||
for _ in 0..c_data {
|
||||
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()
|
||||
let cch = match b.get(at..at + 2) {
|
||||
Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize,
|
||||
None => break,
|
||||
};
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user